Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 2ec0ac3c25 refactor(server): openCodeOpenOptionsPatch takes one named-fields object 2026-11-05 15:00:18 +01:00
Mohamed Boudra 6cb9685a6d fix(server): plugin option applies run one at a time, and OpenCode reports the Auto Accept legacy full-access turns on
Plugin sessions let two applyOptions overlap. The second finished and re-recorded the
settings the plugin held, which included a change the first had already made and not yet
reported; when the first then failed, nothing was left to report and the stored options
kept the old value while the plugin ran the new one. Applies now queue on the session,
the way the ACP adapter's do, so each reconciles against what the one before left.

OpenCode normalizes the legacy full-access mode to build with Auto Accept on, but the
session never said so, so the options the manager keeps did not hold Auto Accept and a
reopen turned it off. The session now reports it through openOptionsPatch on create and
resume.
2026-11-05 12:28:49 +01:00
Mohamed Boudra 8a972b8c2e fix(server): an ACP removal reports the choice the select lands on, and the Codex plan tests enable plan mode
A removal asks the select for its empty choice and compared the result against that
same empty choice, so applyOptions returned nothing and the manager stored the removal
as null while the session ran the Default choice. The difference is now computed against
what was asked (null), so the options hold the value the session runs with.

The Codex plan-mode end-to-end tests called the removed session.setFeature through an
optional call and silently skipped plan mode; they apply the option and fail loudly if
the method is gone. The MCP parity wrapper forwards listControls instead of the removed
listFeatures.
2026-11-04 09:35:24 +01:00
Mohamed Boudra cc56b392de fix(server): ACP option applies run one at a time
Two overlapping applies shared one transaction guard, so when the first was refused after the agent had moved a select, its report was dropped as 'still in a transaction' and the second saw no difference. Applies are serialized, as OpenCode's are, so each reconciles against the state the previous one left.
2026-11-04 09:35:23 +01:00
Mohamed Boudra cb331532ad fix(server): draft speed shows the tier the first turn will run with
Draft discovery reported the options as requested, so a Fast configured as a legacy fast_mode or the fast alias read as Normal while creating sent the model's own Fast tier. The draft now reports the options the scratch session opened with, as registration does.
2026-11-04 09:35:23 +01:00
Mohamed Boudra da994fb400 fix(server): profile options convert against the provider graph a write commits
A write that edited a provider and a profile together folded the profile against a shallow merge that dropped the provider's extends, so its legacy Fast was not converted. The fold now sees the same deep-merged providers the write commits.
2026-11-04 09:35:23 +01:00
Mohamed Boudra c4baf750f2 fix(server): a profile is stored in the current option keys, converted for the adapter its provider extends
Profiles in config.json are old data entering, so the store converts them (stored value and incoming patch) with the same conversion the agent manager uses. Storage and clients now share one vocabulary: resetting a legacy Codex profile's speed removes what was shown, and a malformed fast_mode rejects the write instead of breaking later config reads. The config API goes back to copying the field shape only. The extends chain is followed by one function over either the registry's definitions or config.json's providers.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 31a408d108 fix(server): a malformed Codex fast_mode is rejected, and profile options are renamed for clients by the agent manager's conversion
convertLegacyOptions read any truthy fast_mode as Fast, so the string "false" ran priority. It now accepts only true, false or null. The profile store no longer renames keys: a literal codex profile was renamed in storage while an extends-codex profile was not. The config API now converts each profile's options with the manager's adapter-aware conversion, so codex and my-codex profiles read the same.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 3be00f5105 fix(server): OMP turn completion publishes a Fast change, and Claude opens with Fast off on a model that has none
OMP installed runtime state in several places and only one compared Fast; every state read from the runtime now goes through one install step. Claude kept a Fast default on a model without Fast, which made unrelated options reject until the user cleared it; the session now opens with Fast off and reports it in openOptionsPatch, as the model-switch path already does.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 6e704f803c fix(server): an OMP replacement runtime that refuses the session's Fast publishes what it accepted
The approval-mode restart installed the new runtime's state without comparing its Fast result to the session's, so a refusal never reached options_changed, the remembered value, or storage. The replacement now goes through the same comparison as refreshState.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 949462f39b fix(server): legacy Fast reaches draft discovery and saved profiles, and overlapping or replaced options keep the state they committed
Draft controls and commands convert the config they hand to the provider, so inspecting a
legacy Fast preference no longer fails Codex's strict options. A profile naming codex converts
its saved fast_mode for the app's controls. OpenCode serializes auto_accept applies so a failed
one restores exactly what it changed, and a replacement OMP runtime starts from the Fast the
session opened with.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 53d91d63da fix(server): a rejected OpenCode auto_accept leaves the session asking for permission
applyOptions committed the new flag before approving pending requests, so a failed reply left
the session auto-approving while the stored option stayed off. The flag is restored when the
apply throws.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 4709b49dc4 refactor(server): the agent manager converts Codex's legacy fast_mode for the adapter behind the provider id
The rename is adapter-specific, so it lives where the provider id resolves to its adapter:
AgentManager converts an agent's own options on create, resume, import, reload, apply and
draft controls, following derivedFromProviderId, so a custom provider extending codex keeps
working with a saved or requested fast_mode. Storage, MCP, schedules, hub and profiles pass
options through. The featureValues-to-options fold stays provider-agnostic at the legacy edges.
2026-11-04 09:35:23 +01:00
Mohamed Boudra f938bf963d fix(server): a legacy hook's replaced speed and an apply request's options convert before they are compared or applied
A hook that swaps the canonical speed it was sent for fast_mode is diffed in canonical form, so
its replacement is not read as a deletion. An apply request carrying featureValues and options
sends one options patch with options winning, so a superseded legacy value is never applied.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 1131241b73 refactor(server): Codex's legacy fast_mode converts to service_tier where old data enters
Stored records, config.json defaults, profiles, older clients' requests, MCP settings and
plugin hooks go through convertLegacyOptions as they come in. Past that point the defaults
merge, the manager and the Codex session never see fast_mode for Codex, so the alias branch
in the defaults merge and Codex's fast_mode handling are deleted.
2026-11-04 09:35:23 +01:00
Mohamed Boudra ce39ab6f09 fix(server): an OMP replacement runtime starts with the Fast the session has now
A restart (for example an approval-mode change) restored Fast from the options the session opened with, turning back on a Fast the user had switched off. The session now keeps the Fast it applied or the provider changed, which is what a replacement reads.
2026-11-04 09:35:23 +01:00
Mohamed Boudra c242f4d8db fix(server): the legacy Codex Fast alias folds into one speed on apply and in configured defaults
A patch with both speed keys left the alias in the stored options, so a reload started Fast again after the speed was removed; the apply now reports the folded alias. A configured fast_mode default now supplies the service_tier default, so removing an agent's own speed falls back to the configured Fast like opening does.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 66aeff70d1 fix(server): Codex keeps a Fast chosen by alias as the catalog's tier id
A live fast_mode or service_tier "fast" was stored as the alias while the apply reported the catalog id, so a later switch to a model with a different Fast id ran Fast under an option that said otherwise. The session now stores the id it accepted and drops the folded fast_mode.
2026-11-04 09:35:23 +01:00
Mohamed Boudra adf8badade fix(server): a saved legacy Codex Fast preference wins over a configured service_tier default
The defaults merge put a configured service_tier next to the agent's own legacy fast_mode, and Codex reads service_tier first, so the agent opened Normal. The agent's own alias now replaces the default of the key it stands for.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 1b45d92dac fix(server): every opening path hands registration the options it launched with
Registration took the launch options as optional and reread the defaults when a path did not supply them, as provider-session import did. It is now required, so create, resume, reload and import each resolve once before opening. The option helpers take named inputs.
2026-11-04 09:35:23 +01:00
Mohamed Boudra 42ed0a8a99 fix(server): opening snapshots keep the defaults the session launched with, and config-change pushes carry legacy profile values
An opening resolved defaults once for the session and again at registration; an edit in between showed options the session never received. Create, resume and reload now resolve once, before the provider opens, and registration starts from that. The daemon_config_changed push skipped the featureValues projection that config replies apply, so the unchanged app lost a profile's legacy values when any setting changed.
2026-11-04 09:35:23 +01:00
Mohamed Boudra d8fca6fdc6 fix(server): Codex keeps the speed it opened with when the legacy Fast key is removed
Opening moves a legacy fast_mode into service_tier, so removing fast_mode afterwards changed the runtime speed to Normal while the stored options still said Fast. Removing the alias now changes no speed.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 77f97f82bc fix(server): an option a session drops when opening is not brought back by a configured default
The opening snapshot merged defaults over the options after the session's openOptionsPatch had removed a key. Defaults now merge into what the session was given and the patch applies after.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 26b1e122ae fix(server): a live apply shows the merged defaults the provider was given in the options snapshot
The snapshot took the raw patch, so a nested patch over configured defaults dropped the defaults the provider had accepted. The snapshot now takes what the provider was sent plus what it reports back; stored options still keep the raw patch.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 7dfb63d0c4 fix(server): a loaded agent's options stay what its session opened with when a provider default is edited
effectiveOptions was recomputed from the current config.json defaults on every state emission, so editing a default changed the snapshot of an agent whose session never received it. It is now computed when the session opens and moves only through an apply or an options_changed. Editing a default affects agents the next time they open.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 5c81d08959 fix(server): ACP reports a select that appears after startup with its native value
A model switch can introduce a select (Cursor Fast) that the session did not have at open.
Its value is now stored like any other native selection instead of being skipped because
the previous snapshot did not name it.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 96b2bea130 refactor(server): option helpers take named inputs, control types come from their schemas, and a plugin accepts launch options it opened with
A whole profile is applied by the unchanged app, so a launch-only plugin option whose value
has not changed must not fail the apply. applyProviderOptionsPatch and featuresFromControls
take one object, and the protocol control types are inferred from the wire schemas.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 33338be16e fix(server): plugins reject keys they do not report as settings, and Codex opening reports the speed it runs
A plugin session applied the settings it knew and silently stored the rest; it now rejects
a patch that names a key the plugin does not report. Codex opening on a model without the
saved speed ran Normal while the stored options still said priority; opening now reports
the tier it runs, as a later model switch does.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 32230e24c5 fix(server): a live options patch is rejected when the session cannot apply it
A session with no applyOptions (Pi) now rejects a patch instead of storing a value that
fails on reopen. Codex applies only service_tier, plan_mode and fast_mode live, since the
approval policy and sandbox are fixed when the thread starts. Claude also rejects a launch
option change while its first query is starting, after the options have been copied.
2026-11-04 09:35:22 +01:00
Mohamed Boudra d641cb5179 fix(server): Claude rejects a live change to an option it reads when the query starts
Replacing a started query to apply the option would end its background shells and
tasks. Once the query exists only Fast can change; before the first prompt every option
still applies, because it is read when the query is built.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 58094053dc fix(server): a live patch is rejected for an option the session only reads at start, and opening reports every option it ended up with
A stored option has to describe what runs. OpenCode, OMP and ACP now reject a live change
to a key they apply only when the agent opens; Claude applies such a change from the next
turn and rejects it while a turn is running. Opening reports every select or setting the
session came up with, including ones nobody set, so a native default (Cursor opening with
Fast on) is stored as option state like any other.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 3f75a11a49 refactor(server): the legacy featureValues folds take one named-input object 2026-11-04 09:35:22 +01:00
Mohamed Boudra f0807c5deb fix(server): a plugin that moves settings and then refuses an apply reports what moved 2026-11-04 09:35:22 +01:00
Mohamed Boudra e0ff0959a1 fix(server): Claude applies only the keys a patch sets over its current options
A Fast patch kept a copy of the options taken before the runtime answered, and
assigning it afterwards dropped a patch that landed meanwhile.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 591dd3bc92 fix(server): Codex returns the tier it resolved and drops a speed the new model lacks
A live service_tier: fast is stored as the catalog's Fast tier id instead of
a value the control cannot show, and a model switch to a model without the
stored speed reports Normal so choosing a supporting model again does not
bring the paid tier back.
2026-11-04 09:35:22 +01:00
Mohamed Boudra 8fb31cf8c0 test(server): removal of auto_accept returns ACP to asking; Claude keeps opening restrictions across a patch 2026-11-04 09:35:22 +01:00
Mohamed Boudra b99d2a010d test(server): assert the forwarded plugin setting without nesting callbacks 2026-11-04 09:35:22 +01:00
Mohamed Boudra 41011ddc60 test(server): a setting a plugin changes on its own is forwarded as options_changed 2026-11-04 09:35:22 +01:00
Mohamed Boudra e9d440259c test(server): a patch key is resolved against nested configured defaults 2026-11-04 09:35:22 +01:00
Mohamed Boudra 0d697e6330 docs(server): a rejected apply reports what the provider moved after the call ends 2026-11-04 09:35:21 +01:00
Mohamed Boudra 5413b8122c fix(server): fold an old app's profile featureValues by the provider and options the write carries
A write with options folds featureValues over those options. A write
without them patches the stored options, unless the profile moved to
another provider, which starts from no options.
2026-11-04 09:35:21 +01:00
Mohamed Boudra 8960e76d19 fix(server): Claude reports Fast as off, not removed, when a model switch drops it
A removed key lets a config.json default of fast_mode: true fill back in
while Fast is off at runtime.
2026-11-04 09:35:21 +01:00
Mohamed Boudra 218bcaaa6b fix(server): providers validate live option patches and Claude reports Fast dropped by a model switch
Live patches go through the schema used at creation. A Claude model switch
that cannot keep Fast reports fast_mode as removed so the stored options
match the session.
2026-11-04 09:35:21 +01:00
Mohamed Boudra 30cc0a8c21 fix(server): ACP reports the selects it moved during open or apply as that call's result
A config_option_update that arrived before the write response was emitted
as an event on top of the result the manager stores. Notifications during
open or apply now fold into the open patch or apply result, and a rejected
apply reports what the agent moved since nothing is stored. Live options
are validated with the creation schema. Tests drive a stdio ACP agent.
2026-11-04 09:35:21 +01:00
Mohamed Boudra a362664f47 fix(server): apply an old app's profile featureValues over the profile's stored options
The unchanged app's profile form only knows the controls, so a name-only
edit dropped options it never shows, such as sandbox_mode. Its
featureValues now apply as a patch over the options already stored for the
profile (null removes a key), and a write with no featureValues keeps them.
2026-11-04 09:35:21 +01:00
Mohamed Boudra 20ce734367 refactor(server): providers return what apply and open ended with instead of reporting it afterwards
applyOptions returns the options the session ended up with and the session
exposes its open difference as openOptionsPatch, so the manager stores the
patch and the provider's answer in one step. Removal is an ordinary patch:
the provider receives the default the key falls back to, or null, and returns
what it kept. Controls no longer take a default from a stored legacy feature
value.
2026-11-04 09:35:21 +01:00
Mohamed Boudra ca2bdd66eb refactor(server): providers report option differences once, after create, resume and apply
Providers used to write their own reports into the agent's options while a session was being
created, resumed or updated, which needed a growing set of reconciliation, baseline and
suppression rules to stay in step with the daemon.

The contract is now transactional. A provider opens or updates with the options it is given and
emits nothing while doing so; if the result differs it says so once, afterwards, as
`options_changed`. Live apply hands the provider only the keys the patch sets and the daemon stores
them onto the agent's current options when the provider accepts, so the last write wins. A change
the provider makes on its own is stored and forwarded, not undone. Removing an option drops the key
and sends nothing live. Controls carry no state; `options` is the only option state.

A legacy `agent.create.before` hook's `featureValues` edits are applied as a patch over the
returned options, so its deletions and replacements hold. The unreleased `aliases` field on option
choices is gone; a pre-v0.10 Codex Fast preference is reported once as the model's tier id.
2026-11-04 09:35:21 +01:00
Mohamed Boudra 1aaac5b231 chore(website): regenerate the config schema for profile options 2026-11-04 09:35:21 +01:00
Mohamed Boudra b60b74384b fix(server): show Fast for a stored Codex fast_mode: true without rewriting the record
Reverts the storage migration from a987b4a43. The Codex speed control now takes its default from
the session's current options with the same rule the runtime uses (an explicit service_tier wins;
fast_mode: true applies only while service_tier is absent). This holds on create, resume, live
apply and for config.json defaults, and stored options stay as set.
2026-11-04 09:35:21 +01:00
Mohamed Boudra 034f639c88 fix(server): read a pre-v0.10 Codex fast_mode record as service_tier "fast" and store it that way
A record holding `fast_mode: true` without `service_tier` ran Fast but the speed control showed
Normal, and picking Normal then wrote "default". The Codex session now normalizes the old boolean
at construction and reports the new shape through its existing options_changed event.
2026-11-04 09:35:21 +01:00
Mohamed Boudra 55e8cff9f4 fix(server): keep snapshot options as configured, restore OMP baseline, cover options through public interfaces
- Codex: drop displayOptions; a select choice may carry aliases so a saved Fast preference selects the model's Fast tier from options + controls alone.
- OMP: removing fast_mode restores the runtime's starting setting.
- ACP/Cursor removal tests run through real session initialization.
- agent.config.apply of a non-control option reaches the provider and shows in the next snapshot (daemon e2e).
- Daemon config store persists legacy-featureValues profiles as options on the next profile save.
2026-11-04 09:35:21 +01:00
Mohamed Boudra 06be203c5c fix(server): address Phase 1 audit findings on option controls
- Removing auto_accept (OpenCode, ACP) turns live approval off; ACP selects return to their session default.
- agent.create.before hook featureValues edits override options the create already set.
- Claude commits fast_mode only after the runtime accepts it.
- Codex snapshots show a saved Fast preference as the tier the model resolves it to (AgentSession.displayOptions).
- Profiles are folded into options in the daemon config store; the config API still answers with featureValues.
- Snapshot options and provider.controls.list carry all effective options.
- Test: Claude fast_mode survives a restart through stored providerOptions.
2026-11-04 09:35:21 +01:00
Mohamed Boudra 007a8e36ae feat(server): fold featureValues into providerOptions and describe composer controls
Providers emit option controls over top-level providerOptions keys; the daemon stores one
options record per agent, merges config.json defaults as effective values, and applies patches
live via AgentSession.applyOptions. Legacy featureValues (wire, stored records, profiles,
schedules, hooks, MCP) fold into providerOptions; snapshots still carry features built from
controls. Snapshot options expose only keys that have a control.
2026-11-04 09:35:21 +01:00
Mohamed Boudra eb6bed8447 feat(protocol): option controls, options patch, and provider.controls.list
Add AgentOptionControl descriptors, snapshot options/controls, an options patch on
agent.config.apply, provider.controls.list RPC, and server_info.features.providerOptionControls.
featureValues stays accepted on the wire (COMPAT).
2026-11-04 09:35:21 +01:00
221 changed files with 6965 additions and 6207 deletions
-1
View File
@@ -55,7 +55,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/terminal-rendering.md](docs/terminal-rendering.md) | Native terminal view: xterm owns the grid, cluster placement, emoji widths, mixed-direction limits, render regression |
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
| [docs/agent-stream-disclosure.md](docs/agent-stream-disclosure.md) | Expand/collapse in the chat timeline — hold contract, native anchor sequence, frame spacing, render-window cost |
| [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints |
| [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations |
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
-163
View File
@@ -1,163 +0,0 @@
# 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.
## 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)` (`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.
Following output:
- A hold always detaches the bottom anchor controller (`holdForDisclosure`).
- 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 in the reading anchor and runs `apply`; the next reading reconcile restores
the row's top after layout, before paint (`strategy-web.tsx`).
## 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.
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`). |
| 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. |
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)` 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
`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.
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 ten 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.
+4 -4
View File
@@ -34,10 +34,10 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
- **A history row re-renders only when its item or layout item identity changes.** Every
coalesced tick hands the list a new layout. Without a memo boundary each tick re-rendered every
mounted row (measured on the inverted FlatList native used before LegendList: about 50 rows on a
phone, 100–250 ms of JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed,
- **A history row re-renders only when its item or layout item identity changes.** The inverted
FlatList hands every mounted cell a new `index` and `ref` whenever a row is prepended, so without a
memo boundary each coalesced tick re-rendered every mounted row (about 50 on a phone, 100–250 ms of
JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed,
`useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state,
or breakpoint changed, and `StreamRow` memoizes on both. Every viewport runs its history
through that hook; the web viewport once skipped it and history hosts of a live tool group went
+10 -10
View File
@@ -174,18 +174,18 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
## Scroll view corrections
## Inverted timeline input
`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which
keeps React Native's scroll manager interface and corrects `maintainVisibleContentPosition` for the
chat timeline. [agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections)
lists the corrections and the React Native issues they work around. Its instrumentation test,
`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager:
Android focus and selection visibility requests must not reposition inverted timelines. The
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
child-reveal scroll calculations when the view's transform matrix inverts the vertical axis. React
Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotation with positive
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
commands still work; non-inverted scroll views keep Android's default behavior.
```bash
cd packages/app/android
./gradlew :paseo-scroll:connectedDebugAndroidTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false
```
Mouse wheel axes also bypass the inversion transform. The same module reverses the vertical
wheel axis for inverted scroll views before delegating to Android, preserving native scroll
bounds and React Native's scroll events and disabled-scroll behavior.
Register this package before React Native's core package through its Expo config plugin. Normal
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
+3 -5
View File
@@ -68,11 +68,9 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px in the compact layout, so every phone control is a full touch target; the breakpoint, not the caller, makes that choice. `sm` is 28px with `fontSize.sm` labels and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size.
Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `<Switch>` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 where controls are touch-sized, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `<Switch>` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 in the compact layout, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
@@ -152,7 +150,7 @@ One row off the rail makes the whole card look unconsidered.
Compact-first. The small case is designed; the large case adds chrome around it.
Use `md: 600` for the compact/wide layout boundary, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint; touch sizing comes from the control sizing gate in [Buttons](#4-buttons). Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
Use `md: 600` for both the compact/wide layout boundary and responsive density, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint. Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) share the canonical list+detail navigation pattern:
+6 -4
View File
@@ -157,7 +157,8 @@ and the stream keeps its existing flick-to-dismiss gesture.
The dock's private viewport and capacity model are extracted from chat. The
app-wide `@/keyboard/shift` module owns shared motion for the dock, portals, streams,
terminal, explorer, and dialogs. Import only its public entry point: the provider,
shift hooks, shift style, translate view, and keyboard anchor. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
shift hooks, shift style, translate view, keyboard anchor, settled stream inset, and explorer padding
policy. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
at its root mount point outside the portal host when changing dock hosts.
`KeyboardTranslateView` owns visual keyboard motion inside `@/keyboard/shift`. Android uses the
@@ -170,14 +171,15 @@ animation. Keep this platform choice inside the keyboard module's private platfo
`KeyboardTranslateView`. The panel root clips it at the header edge, so rows
slide under the header on open and out from under it on close. Do not swap the
dock to keyboard padding at rest. Changing its height creates either a blank
band or a paused transition when the stream list updates its viewport.
band or a paused transition when the inverted list updates its viewport.
Do not animate a layout prop through the keyboard transition. On Android
Fabric, each animated layout update clones the shadow tree and runs Yoga over
the dock subtree. This measured about 10 ms per frame on the emulator and the
mounted layouts arrived in bursts every 2–4 frames. A transform does not dirty
layout. The stream list gets no keyboard inset either; see
[who owns the offset](agent-stream-disclosure.md#who-owns-the-offset).
layout. Preserve the translated dock's history scroll range with a far-end
content inset on the inverted stream list. Update that inset only when keyboard
motion settles; never drive it per frame.
The translated dock's height is not the composer's available height. Bound the
composer against the stationary space below the header inside
-2
View File
@@ -26,8 +26,6 @@ Native code blocks use the library's copy control and compiled grammars. Web cod
The package patch also keeps overflowing native horizontal blocks in control of their drags: Android claims parent interception at touch-down and releases it for vertical drags so the chat list can scroll; iOS keeps its scroll recognizer active for outward drags at the leading edge. This prevents compact sidebar pans from taking block scrolling without changing sidebar swipes on prose or non-overflowing blocks. Native changes require a client rebuild.
On iOS a newly mounted Markdown view paints empty for a frame or more after it is laid out at its measured height: the library parses even a view's first content on a background queue and applies it in a later main-queue turn. Expanding a turn in chat therefore shows its rows in place with the text arriving a frame later (165 ms when the mount keeps the main thread busy). Android paints the text in the first frame. Rendering a view's first content synchronously fixes the frame but costs frame time when a fling mounts history rows, so the app keeps the library's behavior until [enriched-markdown #1131](https://github.com/software-mansion/enriched-markdown/issues/1131) is resolved.
Android measurements use a size-only, 512-entry LRU across view unmounts, matching the library’s existing iOS cache. Scrolling back through history can reuse layout sizes without parsing those rows again. Content, layout props and width identify each entry; font-scale changes and native layout invalidation discard affected sizes. The cache retains no views or spans.
The library determines how incomplete Markdown appears during streaming. Completed text uses the same component and engine as history. Find and scroll-to-message address source message ids; selection-copy understands semantic HTML and the web code component's copy metadata.
+10 -1
View File
@@ -452,9 +452,18 @@ Regular `View` components can safely use Unistyles dynamic styles — the confli
## Native Chat Stream Layout
The native agent stream uses an inverted `FlatList`, so chat layout has three coordinate systems:
- chronological stream order
- strategy-ordered array order
- native inverted cell visual order
Do not compute stream neighbors, history/live-head seams, turn footer ownership, assistant block spacing, or tool sequence endings inside React render loops. Those policies live in `packages/app/src/agent-stream/layout.ts` and are unit-tested without React Native rendering.
Platform-specific stream edges belong on `StreamStrategy`. Web and native both render in chronological order, so both use the last history item as the history/live-head boundary and render content before a footer.
Platform-specific stream edges belong on `StreamStrategy`:
- forward web uses the last history item as the history/live-head boundary and renders content before a footer
- native inverted uses the first history item as the history/live-head boundary and compensates for inverted cell child order
If a chat footer looks duplicated or appears above the assistant message on mobile, start with `packages/app/src/agent-stream/layout.test.ts`. Do not add a React Native renderer test for this class of bug; make the pure layout invariant fail first.
+9
View File
@@ -2,6 +2,15 @@
Keep completed assistant prose in one native text view so selection crosses paragraphs, headings and lists. The shared [Markdown renderer](markdown-renderer.md) owns that view.
## Android inverted chat
Keep selection visibility handling in `modules/paseo-scroll`. Android's visibility
rectangles ignore list inversion. React Native decomposes Android's `scale: -1`
into rotation with positive `scaleY`, so test the composed matrix when deciding
whether those requests should move the viewport. Native tests must apply the
list's transform through React Native's view manager; assigning `scaleY` directly
misses that decomposition.
## Diagrams in lists and quotes
Selection stops at a Mermaid diagram. Native extracts Mermaid fences into separate views because the library exposes no native block renderer override. A diagram inside a list or quote therefore appears outside that container, and its following prose starts a separate Markdown fragment.
+47 -3
View File
@@ -30,6 +30,50 @@ at session startup. See [provider configuration](custom-providers.md#provider-op
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
for native keys and examples.
### Options flow
`options` on the agent snapshot is the only option state. Controls describe how to draw an
option and read `options`; they hold no value of their own. Any option can be read and set with
or without a control.
- **Create and resume.** The provider opens with the options it is given and emits nothing about
them while opening. `openOptionsPatch` carries every option the session ended up with that
differs from what it was given, including a setting nobody asked for (a native default such as
Cursor opening with Fast on). It is set before `createSession`/`resumeSession` resolves and the
daemon stores it before the agent is published. A reopened agent resumes that state, so a
native default the provider later changes does not follow. `options` on the snapshot is what
the session opened with, and afterwards it moves only through an apply or an `options_changed`.
Editing a provider default in `config.json` affects agents the next time they open.
- **Live apply.** `applyOptions` receives the keys the patch touches, resolved against the
`config.json` defaults. A removal arrives as the default the key falls back to, or `null` when
there is none; the provider returns the setting to its own default if it can, or throws to
reject. A key the running session cannot apply (one the provider reads only when it opens) is
rejected too, so a stored option always describes what runs. Claude reads most options when it
starts its query, and replacing a started query would end its background work, so after the
first prompt only Fast can change. Codex fixes its approval policy and sandbox when the thread
starts, so it applies only `service_tier` and `plan_mode` live. A session with no
`applyOptions` rejects every patch; the daemon enforces this. The provider returns the options
it ended up with when they differ from the request. The daemon
stores the patch and that result in one step after the call resolves, onto the agent's options
as they are at that moment, so the last write wins. Throwing rejects the whole patch and
none of it is stored. Emit nothing from inside the call. If the provider's own state moved
before it refused, it reports the moved values once as `options_changed` after the call has
ended, because the stored options must match what runs.
- **Old data converts in the agent manager.** Codex used to take `fast_mode`; it takes
`service_tier` now. `AgentManager` runs `convertLegacyOptions` (`option-controls.ts`) on an
agent's own options when it creates, resumes, imports, reloads or applies to it, and on draft
controls, before defaults merge and before anything is stored (`true` is `"fast"`, `false` is
`"default"`, and a `service_tier` in the same input wins). The manager converts for the adapter
behind the provider id, following `derivedFromProviderId`, so a custom provider with
`extends: "codex"` is converted too. Configured defaults convert in the provider registry. A
stored record keeps `fast_mode` until the agent is next opened or applied to, and is stored
converted from then on. Nothing past the manager sees `fast_mode` for Codex. Codex resolves
`"fast"` to the model's Fast tier and reports the id it runs through `openOptionsPatch` or the
`applyOptions` result like any other value. The `featureValues` to `options` fold is separate
and provider-agnostic; it stays at the protocol and legacy edges.
- **Provider-initiated changes.** A setting the provider changes on its own is an
`options_changed` event. The daemon stores and forwards it and does not push the old value back.
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
closed for Hub unattended execution until it can approve one exact injected MCP
server and tool identity without approving native tools.
@@ -115,7 +159,7 @@ Submitted user-message wire items carry the same Paseo ID in `messageId` and `cl
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listControls` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session control listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listControls` implementation may return controls from an incomplete, model-less draft and owns which features are valid in that state.
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
@@ -478,7 +522,7 @@ interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
readonly features?: AgentFeature[];
readonly controls?: AgentOptionControl[];
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
@@ -500,7 +544,7 @@ interface AgentSession {
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
setFeature?(featureId: string, value: unknown): Promise<void>;
applyOptions?(options: ProviderOptions): Promise<void>;
tryHandleOutOfBand?(prompt: AgentPromptInput): {
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
} | null;
+3 -4
View File
@@ -95,10 +95,9 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
The daemon validates the epoch and locates the projected item at the selected position. A fork
includes projected items through that checkpoint. If an item the fork copies spans the checkpoint
and changed afterward, the daemon refuses that fork with an actionable error: discarded historical
payloads cannot be reconstructed from sequence metadata. Plugin cards are not copied, so a card
updated after the checkpoint does not block it. Forking the current context remains available.
includes projected items through that checkpoint. If an item spans the checkpoint and changed
afterward, the daemon refuses that fork with an actionable error: discarded historical payloads
cannot be reconstructed from sequence metadata. Forking the current context remains available.
## Resume behavior
+1 -1
View File
@@ -1 +1 @@
sha256-sLaLHD1u1eJRdGdflcwWBGxnnhRQpB1T74BlaWcmxbU=
sha256-p97CaALgkY+x9XVvS4/ILqmbwYSX0fSbVUgQl35BjiU=
-21
View File
@@ -8142,26 +8142,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@legendapp/list": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.7.1.tgz",
"integrity": "sha512-zgIy84yG2JP93+kib61rZBTcYGZ6fULrkCQz/MF7WrjVJeJQa/rR4z+rXfM5oveirO5mYkro4IDGytARIjJe9w==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.5.0"
},
"peerDependencies": {
"react": "*"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
@@ -37486,7 +37466,6 @@
"@getpaseo/protocol": "*",
"@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14",
"@legendapp/list": "^3.7.1",
"@marijn/find-cluster-break": "^1.0.2",
"@mattermost/react-native-paste-input": "2.0.1",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -928,11 +928,11 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
throw new Error("Compact Changes toolbar geometry could not be measured");
}
expect(actionsBox.width).toBe(48);
expect(actionsBox.height).toBe(32);
expect(optionsBox.width).toBe(44);
expect(optionsBox.height).toBe(44);
expect(glyphBox.width).toBe(20);
expect(glyphBox.height).toBe(20);
expect(actionsBox.height).toBe(28);
expect(optionsBox.width).toBe(32);
expect(optionsBox.height).toBe(32);
expect(glyphBox.width).toBe(18);
expect(glyphBox.height).toBe(18);
await expect(actions).not.toContainText("Commit");
await expect(actions.locator("svg")).toHaveCount(2);
@@ -1,6 +1,5 @@
import type { Page, TestInfo } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { pauseWorkspaceTerminalWorker } from "../support/helpers/workspace-setup";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
@@ -191,49 +190,3 @@ test("compact header menu runs, shows, and stops workspace scripts", async ({ pa
await workspace.cleanup();
}
});
async function restartScriptAcrossHeaderRemoval(page: Page, terminalId: string) {
await openRunningScriptPage(page, "lint");
const resumeWorker = await pauseWorkspaceTerminalWorker(terminalId);
try {
await menuRow(page, "Restart").click();
await page.setViewportSize({ width: 1280, height: 844 });
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveCount(0);
await expect(page.getByTestId("workspace-scripts-button")).toBeVisible();
} finally {
resumeWorker();
}
}
async function expectScriptTerminalReplaced(
workspace: Awaited<ReturnType<typeof openWorkspaceWithScripts>>,
previousId: string,
) {
await expect
.poll(
async () => {
const after = await workspace.client.listTerminals(undefined, undefined, {
workspaceId: workspace.workspaceId,
});
return after.terminals.filter((terminal) => terminal.id !== previousId).length;
},
{ timeout: 15_000 },
)
.toBe(1);
}
test("pending script restart survives removal of the compact header", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const workspace = await openWorkspaceWithScripts(page);
try {
await runScriptFromScriptsPage(page, "lint");
const before = await workspace.client.listTerminals(undefined, undefined, {
workspaceId: workspace.workspaceId,
});
expect(before.terminals).toHaveLength(1);
await restartScriptAcrossHeaderRemoval(page, before.terminals[0]!.id);
await expectScriptTerminalReplaced(workspace, before.terminals[0]!.id);
} finally {
await workspace.cleanup();
}
});
@@ -5,7 +5,6 @@ import {
recordComposerToolbarFrames,
} from "../support/helpers/composer-control-density";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
const SETTLE_MS = 1_000;
@@ -93,34 +92,3 @@ test.describe("Composer control density across tab switches", () => {
}
});
});
test.describe("Composer controls folding on a touch device", () => {
test.use({ viewport: { width: 1194, height: 834 }, isMobile: true, hasTouch: true });
test.describe.configure({ timeout: 180_000 });
test("an open menu stays closed after the controls fold and unfold", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-density-fold-",
title: "Folding chat",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, session);
const thinking = page.getByTestId("agent-thinking-selector").filter({ visible: true });
const thinkingMenu = page.getByTestId("combobox-desktop-container");
await thinking.click();
await expect(thinkingMenu).toBeVisible();
// Still a wide layout, but too narrow for separate controls: they fold into the model control.
await page.setViewportSize({ width: 640, height: 834 });
await expect(thinking).toHaveCount(0);
await page.setViewportSize({ width: 1194, height: 834 });
await expect(thinking).toBeVisible();
await expect(thinkingMenu).toHaveCount(0);
} finally {
await session.cleanup();
}
});
});
@@ -317,10 +317,7 @@ test.describe("Plugin sidebar items", () => {
const compactSync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync");
await expect(compactSync).toBeInViewport();
await expectFooterRow(page, compactSync);
// The compact nav block is capped at half the sidebar and scrolls past it.
const compactDeploys = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
await compactDeploys.scrollIntoViewIfNeeded();
await expect(compactDeploys).toBeInViewport();
await expect(headerRow(page, SHOWCASE_PLUGIN_ID, "deploys")).toBeInViewport();
await qaScreenshot(page, "phase2-compact-sidebar");
await qaScreenshot(page, "phase5-compact-footer", sidebarFooter(page));
await compactSync.click();
@@ -1,4 +1,3 @@
import { getServerId } from "../support/helpers/server-id";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { spawnSync } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
@@ -488,175 +487,3 @@ test("keeps the shared Find controls usable in a compact dark browser", async ({
await page.getByRole("button", { name: "Close Find" }).click();
await expect(input(page)).toBeFocused();
});
// A phone browser installs xterm's document-level touch gesture listener too.
// Keep this journey touch-enabled so it catches duplicate scroll handling.
test.describe("terminal history with a finger", () => {
test.use({ isMobile: true, hasTouch: true, viewport: { width: 390, height: 844 } });
test("scrolls history once per finger drag", async ({ page }) => {
await openPhoneTerminalHistory(page);
await expectSingleFingerScroll(page, 24);
await expectSingleFingerScroll(page, 1);
await expectMouseWheelScrollsHistory(page);
});
for (const mode of ["alternate", "mouse"] as const) {
test(`preserves finger navigation in ${mode} input mode`, async ({ page }) => {
await openPhoneInputCapture(page, mode);
await expectFingerNavigationInput(page, mode);
});
}
});
async function expectFingerNavigationInput(page: Page, mode: "alternate" | "mouse") {
await dragTerminalWithFinger(page, 24, async () => {
const prefix = mode === "alternate" ? "TOUCH_BYTES:1b4f41" : "TOUCH_BYTES:1b5b3c3634";
await expect.poll(() => getTerminalBufferText(page)).toContain(prefix);
});
}
async function openPhoneTerminalHistory(page: Page) {
await harness.createTerminal({
name: "Finger scroll history",
command: "bash",
args: [
"--noprofile",
"--norc",
"-c",
"for i in $(seq 1 150); do echo HISTORY_$i; done; exec sleep 300",
],
});
await openPhoneTerminal(page);
await expect.poll(() => getTerminalBufferText(page)).toContain("HISTORY_150");
}
async function openPhoneInputCapture(page: Page, mode: "alternate" | "mouse") {
const escapes = mode === "alternate" ? "\x1b[?1049h\x1b[?1h" : "\x1b[?1000h\x1b[?1006h";
await harness.createTerminal({
name: "Touch application input",
command: process.execPath,
args: [
"-e",
`
process.stdin.setRawMode(true);
process.stdin.once("data", () => {
process.stdin.on("data", chunk => {
process.stdout.write("\\r\\nTOUCH_BYTES:" + chunk.toString("hex") + ":END\\r\\n");
});
process.stdout.write(${JSON.stringify(escapes)} + "TOUCH_INPUT_READY\\r\\n");
});
process.stdout.write("TOUCH_START_READY\\r\\n");
`,
],
});
await openPhoneTerminal(page);
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_START_READY");
await input(page).press("x");
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_INPUT_READY");
await expect
.poll(() => page.evaluate(() => window.__paseoTerminal!.buffer.active.type))
.toBe(mode === "alternate" ? "alternate" : "normal");
await expect
.poll(() => page.evaluate(() => window.__paseoTerminal!.modes.mouseTrackingMode))
.toBe(mode === "mouse" ? "vt200" : "none");
}
async function openPhoneTerminal(page: Page) {
await page.goto("/");
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await page.getByTestId(`sidebar-workspace-row-${getServerId()}:${harness.workspaceId}`).click();
await expect(page.getByRole("textbox", { name: "Terminal input" })).toBeVisible();
await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toBeVisible();
}
async function expectMouseWheelScrollsHistory(page: Page) {
const beforeWheel = (await viewport(page))!;
await page.mouse.move(200, 300);
await page.mouse.wheel(0, -120);
await expect.poll(() => viewport(page)).toBeLessThan(beforeWheel);
}
async function expectSingleFingerScroll(page: Page, moves: number) {
await dragTerminalWithFinger(page, moves, async ({ lineHeight, before }) => {
await expect.poll(async () => before - (await viewport(page))!).toBeGreaterThan(0);
const moved = before - (await viewport(page))!;
await test.info().attach("finger-scroll", {
body: JSON.stringify({ moves, distancePx: 120, lineHeight, rowsMoved: moved }),
contentType: "application/json",
});
expect(moved, "one gesture must not scroll twice its physical distance").toBeLessThanOrEqual(
Math.ceil(120 / lineHeight),
);
});
}
async function dragTerminalWithFinger(
page: Page,
moves: number,
inspect: (state: { lineHeight: number; before: number }) => Promise<void>,
) {
await test.step(`Drag history with ${moves} touch moves`, async () => {
const screen = page
.getByTestId("terminal-surface")
.filter({ visible: true })
.locator(".xterm-screen");
const box = await screen.boundingBox();
if (!box) throw new Error("Terminal screen is not visible");
const rows = await page.evaluate(() => window.__paseoTerminal!.rows);
const lineHeight = box.height / rows;
const before = (await viewport(page))!;
const session = await page.context().newCDPSession(page);
try {
const start = { x: box.x + box.width / 2, y: box.y + box.height / 3 };
await session.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ ...start, id: 1 }],
});
for (let step = 1; step <= moves; step++) {
await session.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: start.x, y: start.y + (120 * step) / moves, id: 1 }],
});
}
// Check before lifting the finger to exclude inertial scrolling.
await inspect({ lineHeight, before });
} finally {
await session.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await session.detach();
}
});
}
// Diagnostic-only: application modes emitted before the first attachment.
// The existing live-mode tests above are the control for this failure.
test.describe("mouse mode enabled before terminal attachment", () => {
test.use({ isMobile: true, hasTouch: true, viewport: { width: 390, height: 844 } });
test("preserves SGR wheel reports when opening a running terminal", async ({ page }) => {
await openPreAttachedMouseCapture(page);
await expectFingerNavigationInput(page, "mouse");
});
});
async function openPreAttachedMouseCapture(page: Page) {
await harness.createTerminal({
name: "Mouse mode before attachment",
command: process.execPath,
args: [
"-e",
`
process.stdin.setRawMode(true);
process.stdin.on("data", chunk => {
process.stdout.write("\\r\\nTOUCH_BYTES:" + chunk.toString("hex") + ":END\\r\\n");
});
process.stdout.write("\\x1b[?1000h\\x1b[?1006hTOUCH_INPUT_READY\\r\\n");
`,
],
});
await openPhoneTerminal(page);
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_INPUT_READY");
await expect
.poll(() => page.evaluate(() => window.__paseoTerminal!.modes.mouseTrackingMode))
.toBe("vt200");
}
@@ -6,11 +6,6 @@ env APP_ID=sh.paseo.debug
# Hold past drag arming (180ms), then release.
open "${APP_ID}"
press "label=\"Open menu\""
wait "id=\"sidebar-close\"" 10000
press "id=\"sidebar-close\""
# The close target must clear system controls, including a trailing status column.
wait "label=\"Open menu\"" 5000
press "label=\"Open menu\""
wait "id=\"${WORKSPACE_ROW}\"" 10000
longpress "id=\"${WORKSPACE_ROW}\"" 250
press "id=\"${WORKSPACE_ROW}\""
@@ -1,5 +1,3 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { realpathSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -382,21 +380,3 @@ export function observeSetupPanelTraffic(page: Page) {
export function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string {
return detail.commands.map((command) => command.log).join("\n");
}
/** Hold this isolated daemon's terminal worker so script exit cannot race a layout change. */
export async function pauseWorkspaceTerminalWorker(terminalId: string): Promise<() => void> {
const home = process.env.E2E_PASEO_HOME;
if (!home) throw new Error("Missing isolated E2E home");
const directory = path.join(home, "terminal-worker");
for (const name of await readdir(directory)) {
if (!name.endsWith(".json")) continue;
const record = JSON.parse(await readFile(path.join(directory, name), "utf8")) as {
pid: number;
terminals: Array<{ id: string }>;
};
if (!record.terminals.some((terminal) => terminal.id === terminalId)) continue;
process.kill(record.pid, "SIGSTOP");
return () => process.kill(record.pid, "SIGCONT");
}
throw new Error(`No isolated terminal worker owns ${terminalId}`);
}
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Hosts a scroll view in a real window, so layout clamps and draws run as they do on device. -->
<activity android:name="android.app.Activity" android:exported="false" />
</application>
</manifest>
@@ -1,77 +0,0 @@
package sh.paseo.scroll;
import android.content.Context;
import com.facebook.react.bridge.BridgeReactContext;
import com.facebook.react.bridge.UIManager;
import com.facebook.react.bridge.UIManagerListener;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcherProvider;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.List;
/**
* A React context with a running Fabric UIManager, so scroll views find the mount listener
* registry and event dispatcher the way they do in the app. The UIManager only records mount
* listeners and the dispatcher drops events; every other call returns a default. Java, because
* {@link EventDispatcherProvider} is internal to React Native's Kotlin module.
*/
final class MountingReactContext extends BridgeReactContext implements EventDispatcherProvider {
final List<UIManagerListener> mountListeners = new ArrayList<>();
final UIManager uiManager;
private final EventDispatcher eventDispatcher = inert(EventDispatcher.class, null);
MountingReactContext(Context base) {
super(base);
uiManager = inert(UIManager.class, this);
}
/** Runs one Fabric mount: listeners see willMountItems, then the change, then didMountItems. */
void mount(Runnable change) {
for (UIManagerListener listener : new ArrayList<>(mountListeners)) listener.willMountItems(uiManager);
change.run();
for (UIManagerListener listener : new ArrayList<>(mountListeners)) listener.didMountItems(uiManager);
}
@Override
public boolean hasActiveReactInstance() {
return true;
}
@Override
public UIManager getFabricUIManager() {
return uiManager;
}
@Override
public EventDispatcher getEventDispatcher() {
return eventDispatcher;
}
private static <T> T inert(Class<T> type, MountingReactContext owner) {
return type.cast(
Proxy.newProxyInstance(
type.getClassLoader(),
new Class<?>[] {type},
(proxy, method, args) -> {
switch (method.getName()) {
case "addUIManagerEventListener":
owner.mountListeners.add((UIManagerListener) args[0]);
return null;
case "removeUIManagerEventListener":
owner.mountListeners.remove((UIManagerListener) args[0]);
return null;
case "getEventDispatcher":
return owner.eventDispatcher;
default:
Class<?> result = method.getReturnType();
if (result == boolean.class) return false;
if (result == int.class) return 0;
if (result == long.class) return 0L;
if (result == float.class) return 0f;
if (result == double.class) return 0.0;
return null;
}
}));
}
}
@@ -0,0 +1,159 @@
package sh.paseo.scroll
import android.graphics.Rect
import android.text.Selection
import android.text.Spannable
import android.view.View
import android.view.InputDevice
import android.view.MotionEvent
import android.os.SystemClock
import android.widget.FrameLayout
import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.bridge.JavaOnlyArray
import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.react.uimanager.DisplayMetricsHolder
import com.facebook.react.uimanager.ReactStylesDiffMap
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.scroll.ReactScrollView
import com.facebook.react.views.scroll.ReactScrollViewManager
import com.facebook.soloader.SoLoader
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ScrollSelectionTest {
@Test fun mouseWheelMovesInvertedViewportInVisualDirection() = withScroll(inverted = true) { scroll, _ ->
val before = scroll.scrollY
withWheel(1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue("Wheel up must move toward older messages: $before -> ${scroll.scrollY}", scroll.scrollY > before)
assertEquals("The caller's event must not change", 1f, event.getAxisValue(MotionEvent.AXIS_VSCROLL))
}
val afterUp = scroll.scrollY
withWheel(-1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue("Wheel down must move toward newer messages: $afterUp -> ${scroll.scrollY}", scroll.scrollY < afterUp)
}
}
@Test fun mouseWheelKeepsOrdinaryScrollDirection() = withScroll(inverted = false) { scroll, _ ->
val before = scroll.scrollY
withWheel(1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue(scroll.scrollY < before)
}
val afterUp = scroll.scrollY
withWheel(-1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue(scroll.scrollY > afterUp)
}
}
@Test fun mouseWheelRespectsDisabledScrolling() = withScroll(inverted = true) { scroll, _ ->
scroll.setScrollEnabled(false)
val before = scroll.scrollY
withWheel(1f) { scroll.dispatchGenericMotionEvent(it) }
assertEquals(before, scroll.scrollY)
}
@Test fun focusingSelectableTextKeepsInvertedViewportStill() = withScroll(inverted = true) { scroll, text ->
val before = scroll.scrollY
assertTrue(text.requestFocus())
assertSame(text, scroll.findFocus())
assertEquals("Text focus must not move the inverted viewport", before, scroll.scrollY)
assertTrue(text.isTextSelectable)
}
@Test fun selectingTextKeepsInvertedViewportStill() = withScroll(inverted = true) { scroll, text ->
val before = scroll.scrollY
Selection.setSelection(text.text as Spannable, 0, 8)
text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
assertEquals("Selection visibility requests must not move the inverted viewport", before, scroll.scrollY)
assertEquals("Selectab", text.text.subSequence(text.selectionStart, text.selectionEnd).toString())
}
@Test fun explicitScrollCommandsStillMoveInvertedViewport() = withScroll(inverted = true) { scroll, _ ->
scroll.scrollTo(0, 600)
assertEquals(600, scroll.scrollY)
}
@Test fun ordinaryScrollViewsStillRevealSelectedText() = withScroll(inverted = false) { scroll, text ->
val before = scroll.scrollY
text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
assertNotEquals(before, scroll.scrollY)
}
@Test fun verticalScaleStillKeepsFocusAndSelectionInPlace() = withScroll(inverted = true, transform = "scaleY") { scroll, text ->
val before = scroll.scrollY
assertTrue(text.requestFocus())
text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
assertEquals(before, scroll.scrollY)
}
@Test fun horizontalScaleStillRevealsSelectedText() = withScroll(inverted = true, transform = "scaleX") { scroll, text ->
val before = scroll.scrollY
text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
assertNotEquals(before, scroll.scrollY)
}
private fun withScroll(inverted: Boolean, transform: String = "scale", check: (ReactScrollView, TextView) -> Unit) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.runOnMainSync {
val appContext = instrumentation.targetContext
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
SoLoader.init(appContext, OpenSourceMergedSoMapping)
val reactContext = ScrollTestContext(appContext)
val context = ThemedReactContext(reactContext, appContext)
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
val scroll = manager.createViewInstance(context)
val content = FrameLayout(context)
val text = TextView(context).apply {
setText("Selectable timeline text", TextView.BufferType.SPANNABLE)
setTextIsSelectable(true)
}
content.addView(text, FrameLayout.LayoutParams(300, 100).apply { topMargin = 1400 })
scroll.addView(content)
content.measure(exact(300), exact(2000))
content.layout(0, 0, 300, 2000)
scroll.measure(exact(300), exact(300))
scroll.layout(0, 0, 300, 300)
// Apply RN's actual Android inversion through the manager's transform decomposition.
val transforms = JavaOnlyArray.of(JavaOnlyMap.of(transform, if (inverted) -1.0 else 1.0))
manager.updateProperties(scroll, ReactStylesDiffMap(JavaOnlyMap.of("transform", transforms)))
if (inverted && transform == "scale") {
assertEquals(1f, scroll.scaleY, 0f)
assertEquals(180f, kotlin.math.abs(scroll.rotation), 0f)
}
scroll.scrollTo(0, 400)
check(scroll, text)
}
}
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
private fun withWheel(axis: Float, check: (MotionEvent) -> Unit) {
val time = SystemClock.uptimeMillis()
val properties = MotionEvent.PointerProperties().apply {
id = 0
toolType = MotionEvent.TOOL_TYPE_MOUSE
}
val coordinates = MotionEvent.PointerCoords().apply {
x = 150f
y = 150f
setAxisValue(MotionEvent.AXIS_VSCROLL, axis)
}
val event = MotionEvent.obtain(
time, time, MotionEvent.ACTION_SCROLL, 1, arrayOf(properties), arrayOf(coordinates),
0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_MOUSE, 0,
)
try {
check(event)
} finally {
event.recycle()
}
}
}
@@ -0,0 +1,19 @@
package sh.paseo.scroll;
import android.content.Context;
import com.facebook.react.bridge.BridgeReactContext;
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcherProvider;
/** Native-only tests use RN's dispatcher for a context without a JS instance. */
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
ScrollTestContext(Context context) {
super(context);
}
@Override
public EventDispatcher getEventDispatcher() {
return BlackHoleEventDispatcher.INSTANCE;
}
}
@@ -1,271 +0,0 @@
package sh.paseo.scroll
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Canvas
import android.os.SystemClock
import android.view.MotionEvent
import android.view.View
import android.widget.FrameLayout
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.react.uimanager.DisplayMetricsHolder
import com.facebook.react.uimanager.ReactStylesDiffMap
import com.facebook.react.uimanager.RootView
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.scroll.ReactScrollView
import com.facebook.react.views.scroll.ReactScrollViewManager
import com.facebook.react.views.view.ReactViewGroup
import com.facebook.soloader.SoLoader
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* A Fabric mount that shrinks the content of a scroll view with `maintainVisibleContentPosition`
* set. The mount runs through React Native's real position keeper, driven by the same
* willMountItems/didMountItems calls Fabric makes, inside a real window so layout clamps and draws
* happen as they do on a device.
*/
@RunWith(AndroidJUnit4::class)
class VisibleContentPositionClampTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private var activity: Activity? = null
@After fun finish() {
activity?.finish()
}
@Test fun shrinkAtTheEndLandsAtTheNewEnd() {
val timeline = openTimeline(anchor = Anchor.END)
timeline.shrinkInOneMount()
assertEquals("Offset after the mount", NEW_END, timeline.scrollY())
assertEquals("Offset in the first frame drawn after the mount", NEW_END, timeline.firstDrawnScrollY())
}
@Test fun shrinkBelowAnUnmovedAnchorIsClampedBeforeTheFrameDraws() {
val timeline = openTimeline(anchor = Anchor.TOP_ROW)
timeline.shrinkInOneMount()
assertEquals("Offset in the first frame drawn after the mount", NEW_END, timeline.firstDrawnScrollY())
assertEquals("Offset after the mount", NEW_END, timeline.scrollY())
}
@Test fun shrinkDuringATouchDragLeavesNoOffsetPastTheEnd() {
val timeline = openTimeline(anchor = Anchor.TOP_ROW)
timeline.beginDrag()
assertTrue("The drag owns the viewport", timeline.scrollY() < CONTENT_HEIGHT - VIEWPORT)
timeline.shrinkInOneMount()
val drawn = timeline.firstDrawnScrollY()
timeline.endDrag()
val settled = timeline.settledScrollY()
assertTrue("Offset $drawn drawn past the end $NEW_END", drawn <= NEW_END)
assertTrue("Offset $settled settled past the end $NEW_END", settled <= NEW_END)
timeline.awaitFrames(10)
assertEquals("Offset kept moving after the drag ended", settled, timeline.scrollY())
}
@Test fun growthAtTheEndFollowsWithoutATouch() {
val timeline = openTimeline(anchor = Anchor.END)
timeline.growInOneMount()
assertEquals("Offset after the mount", CONTENT_HEIGHT + GROWTH - VIEWPORT, timeline.scrollY())
}
// The drag begins before JS stops following and removes the pin, so a line mounting in the drag's
// first frames must not scroll the rows the finger is on.
@Test fun growthAtTheEndDuringATouchDragKeepsTheRowsUnderTheFinger() {
val timeline = openTimeline(anchor = Anchor.END)
timeline.beginDrag()
val dragged = timeline.scrollY()
assertTrue("The drag owns the viewport", dragged < CONTENT_HEIGHT - VIEWPORT)
timeline.growInOneMount()
assertEquals("Offset after the mount", dragged, timeline.scrollY())
assertEquals("Offset in the first frame drawn after the mount", dragged, timeline.firstDrawnScrollY())
timeline.endDrag()
}
private enum class Anchor {
/** A zero-height view at the bottom of the content, first child: the end pin. */
END,
/** A row at the top of the content that reaches into the viewport and does not move. */
TOP_ROW,
}
private inner class Timeline(
val scroll: ReactScrollView,
private val content: ReactViewGroup,
private val endPin: View,
private val probe: DrawProbe,
private val reactContext: MountingReactContext,
) {
private var dragY = 0f
private var downTime = 0L
fun scrollY(): Int {
var y = 0
instrumentation.runOnMainSync { y = scroll.scrollY }
return y
}
fun shrinkInOneMount() = resizeInOneMount(CONTENT_HEIGHT - SHRINK)
fun growInOneMount() = resizeInOneMount(CONTENT_HEIGHT + GROWTH)
private fun resizeInOneMount(height: Int) {
instrumentation.runOnMainSync {
probe.drawnScrollY.clear()
reactContext.mount { layoutContent(height) }
probe.invalidate()
}
}
fun firstDrawnScrollY(): Int {
val deadline = SystemClock.uptimeMillis() + 2000
while (SystemClock.uptimeMillis() < deadline) {
instrumentation.waitForIdleSync()
var drawn: Int? = null
instrumentation.runOnMainSync { drawn = probe.drawnScrollY.firstOrNull() }
drawn?.let { return it }
SystemClock.sleep(16)
}
throw AssertionError("No frame was drawn after the mount")
}
fun beginDrag() {
downTime = SystemClock.uptimeMillis()
dragY = 300f
touch(MotionEvent.ACTION_DOWN)
// Finger moves down, so the content scrolls up from the end and the drag owns the viewport.
repeat(6) {
dragY += 12f
touch(MotionEvent.ACTION_MOVE)
}
}
fun endDrag() {
dragY += 4f
touch(MotionEvent.ACTION_MOVE)
touch(MotionEvent.ACTION_UP)
}
fun settledScrollY(): Int {
var previous = scrollY()
repeat(60) {
awaitFrames(2)
val current = scrollY()
if (current == previous) return current
previous = current
}
throw AssertionError("Offset never settled")
}
fun awaitFrames(count: Int) {
repeat(count) {
instrumentation.waitForIdleSync()
SystemClock.sleep(16)
}
}
fun layoutContent(height: Int) {
content.layout(0, 0, WIDTH, height)
endPin.layout(0, height, 0, height)
}
private fun touch(action: Int) {
instrumentation.runOnMainSync {
val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, WIDTH / 2f, dragY, 0)
scroll.dispatchTouchEvent(event)
event.recycle()
}
}
}
/** The React surface root a scroll view reports its native gestures to. */
private class SurfaceRoot(context: Context) : FrameLayout(context), RootView {
override fun onChildStartedNativeGesture(childView: View?, ev: MotionEvent) {}
override fun onChildEndedNativeGesture(childView: View, ev: MotionEvent) {}
override fun handleException(t: Throwable) {
throw t
}
}
private class DrawProbe(context: Context) : View(context) {
val drawnScrollY = mutableListOf<Int>()
override fun onDraw(canvas: Canvas) {
drawnScrollY += (parent.parent as View).scrollY
}
}
private fun openTimeline(anchor: Anchor): Timeline {
val started =
instrumentation.startActivitySync(
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
activity = started
lateinit var timeline: Timeline
instrumentation.runOnMainSync {
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(started)
SoLoader.init(started, OpenSourceMergedSoMapping)
val reactContext = MountingReactContext(started.applicationContext)
val context = ThemedReactContext(reactContext, started)
val manager =
PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS)
as ReactScrollViewManager
val scroll = manager.createViewInstance(context)
val content = ReactViewGroup(context)
val endPin = View(context).apply { setTag(com.facebook.react.R.id.view_tag_native_id, END_PIN_NATIVE_ID) }
val probe = DrawProbe(context)
if (anchor == Anchor.END) {
content.addView(endPin)
content.addView(probe)
} else {
content.addView(probe)
content.addView(endPin)
}
scroll.addView(content)
manager.updateProperties(
scroll,
ReactStylesDiffMap(
JavaOnlyMap.of("maintainVisibleContentPosition", JavaOnlyMap.of("minIndexForVisible", 0))
),
)
started.setContentView(SurfaceRoot(started).apply { addView(scroll, FrameLayout.LayoutParams(WIDTH, VIEWPORT)) })
timeline = Timeline(scroll, content, endPin, probe, reactContext)
timeline.layoutContent(CONTENT_HEIGHT)
probe.layout(0, 0, WIDTH, PROBE_HEIGHT)
}
timeline.awaitFrames(2)
instrumentation.runOnMainSync { timeline.scroll.scrollTo(0, CONTENT_HEIGHT - VIEWPORT) }
timeline.awaitFrames(2)
assertEquals("Starts at the end", CONTENT_HEIGHT - VIEWPORT, timeline.scrollY())
return timeline
}
private companion object {
const val WIDTH = 300
const val VIEWPORT = 600
const val CONTENT_HEIGHT = 1000
const val SHRINK = 240
const val GROWTH = 120
const val END_PIN_NATIVE_ID = "paseo-end-pin"
const val NEW_END = CONTENT_HEIGHT - SHRINK - VIEWPORT
const val PROBE_HEIGHT = 700
}
}
@@ -1,5 +1,9 @@
package sh.paseo.scroll
import android.graphics.Matrix
import android.graphics.Rect
import android.view.InputDevice
import android.view.MotionEvent
import com.facebook.react.ReactPackage
import com.facebook.react.ViewManagerOnDemandReactPackage
import com.facebook.react.bridge.NativeModule
@@ -24,3 +28,51 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
override fun createViewInstance(context: ThemedReactContext): ReactScrollView =
PaseoScrollView(context)
}
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
private val transformValues = FloatArray(9)
private fun isVerticallyInverted(): Boolean {
// Android FlatList uses scale: -1, which RN decomposes into a 180-degree
// rotation with positive scaleY. Read the resulting transform, not scaleY.
matrix.getValues(transformValues)
return transformValues[Matrix.MSCALE_Y] < 0
}
override fun onGenericMotionEvent(event: MotionEvent): Boolean {
if (!isVerticallyInverted() || event.actionMasked != MotionEvent.ACTION_SCROLL ||
!event.isFromSource(InputDevice.SOURCE_CLASS_POINTER)) {
return super.onGenericMotionEvent(event)
}
if (!scrollEnabled) return false
// Android applies wheel axes directly to scrollY; the inversion transform only
// transforms pointer coordinates. Reverse the vertical axis before delegating so
// platform scroll bounds and scroll events still apply.
val properties = Array(event.pointerCount) { index ->
MotionEvent.PointerProperties().also { event.getPointerProperties(index, it) }
}
val coordinates = Array(event.pointerCount) { index ->
MotionEvent.PointerCoords().also {
event.getPointerCoords(index, it)
it.setAxisValue(MotionEvent.AXIS_VSCROLL, -it.getAxisValue(MotionEvent.AXIS_VSCROLL))
}
}
val correctedEvent = MotionEvent.obtain(
event.downTime, event.eventTime, event.action, event.pointerCount, properties, coordinates,
event.metaState, event.buttonState, event.xPrecision, event.yPrecision,
event.deviceId, event.edgeFlags, event.source, event.flags,
)
return try {
super.onGenericMotionEvent(correctedEvent)
} finally {
correctedEvent.recycle()
}
}
// Both RN focus handling and Android selection visibility requests use this calculation.
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
// movement to user gestures and explicit scroll commands.
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int =
if (isVerticallyInverted()) 0 else super.computeScrollDeltaToGetChildRectOnScreen(rect)
}
@@ -1,142 +0,0 @@
package sh.paseo.scroll;
import android.content.Context;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import androidx.annotation.Nullable;
import com.facebook.react.R;
import com.facebook.react.views.scroll.MaintainVisibleScrollPositionHelper;
import com.facebook.react.views.scroll.ReactScrollView;
import com.facebook.react.views.scroll.ReactScrollViewHelper;
/**
* React Native's vertical scroll view with two corrections. Java, because the content-position
* config type is internal to React Native's Kotlin module.
*/
final class PaseoScrollView extends ReactScrollView {
// The chat timeline's end pin, packages/app/src/agent-stream/native-end-pin.tsx.
private static final String END_PIN_NATIVE_ID = "paseo-end-pin";
private final ViewTreeObserver.OnPreDrawListener clampBeforeDraw =
() -> {
clampToContentEnd();
return true;
};
private @Nullable MaintainVisibleScrollPositionHelper.Config visibleContentPosition = null;
private boolean isClampPending = false;
private boolean isUserDragging = false;
PaseoScrollView(Context context) {
super(context);
}
@Override
public void setMaintainVisibleContentPosition(
@Nullable MaintainVisibleScrollPositionHelper.Config config) {
super.setMaintainVisibleContentPosition(config);
visibleContentPosition = config;
}
// While the timeline follows its end, its end pin anchors the content position, so every line that
// mounts scrolls the content by its height. A drag owns the offset from the frame it begins, and
// JS removes the pin only a round trip later. While the user drags, the correction for the pin is
// skipped, so the line grows below the rows under the finger. iOS's PaseoScrollViewComponentView
// does the same.
// MaintainVisibleScrollPositionHelper is the only caller: it moves the offset by its anchor's move.
@Override
public void scrollToPreservingMomentum(int x, int y) {
if (isUserDragging && isEndPinTheAnchor()) return;
super.scrollToPreservingMomentum(x, y);
}
// The drag begins where React Native emits onScrollBeginDrag.
@Override
protected void handleInterceptedTouchEvent(MotionEvent ev) {
super.handleInterceptedTouchEvent(ev);
isUserDragging = true;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
boolean handled = super.onTouchEvent(ev);
int action = ev.getActionMasked();
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
isUserDragging = false;
}
return handled;
}
// The helper anchors on the first content child from minIndexForVisible that reaches into the
// viewport. The pin is the first child and sits at the bottom of the content, so it always does.
private boolean isEndPinTheAnchor() {
if (visibleContentPosition == null || visibleContentPosition.getMinIndexForVisible() != 0) {
return false;
}
View content = getChildAt(0);
if (!(content instanceof ViewGroup) || ((ViewGroup) content).getChildCount() == 0) return false;
Object nativeId = ((ViewGroup) content).getChildAt(0).getTag(R.id.view_tag_native_id);
return END_PIN_NATIVE_ID.equals(nativeId);
}
// With maintainVisibleContentPosition set, a mount that shrinks the content past the offset would
// apply the shrink twice:
// 1. MaintainVisibleScrollPositionHelper.willMountItems records the anchor's frame.
// 2. The mount lays out the shorter content, and ReactScrollView.onLayoutChange clamps the offset
// to the new end.
// 3. MaintainVisibleScrollPositionHelper.didMountItems adds the anchor's move to the clamped
// offset, so the shrink moves the offset a second time (to the top, for a shrink at the end).
// The clamp waits for the pre-draw pass instead, which runs after didMountItems and before the
// frame draws: the anchor's move applies to the offset the content had before the mount, and the
// clamp only catches what the anchor did not account for.
// https://github.com/react/react-native/issues/58578 tracks it; remove this override once
// https://github.com/react/react-native/pull/58695 ships in the React Native version we use.
@Override
public void onLayoutChange(
View v,
int left,
int top,
int right,
int bottom,
int oldLeft,
int oldTop,
int oldRight,
int oldBottom) {
if (visibleContentPosition == null) {
super.onLayoutChange(v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom);
return;
}
if (!isClampPending) {
isClampPending = true;
getViewTreeObserver().addOnPreDrawListener(clampBeforeDraw);
}
ReactScrollViewHelper.emitLayoutChangeEvent(this);
}
@Override
protected void onDetachedFromWindow() {
cancelPendingClamp();
isUserDragging = false;
super.onDetachedFromWindow();
}
private void clampToContentEnd() {
cancelPendingClamp();
View content = getChildAt(0);
if (content == null || content.getWidth() == 0 || content.getHeight() == 0 || !isShown()) {
return;
}
int viewportHeight = getHeight() - getPaddingTop() - getPaddingBottom();
int maxScrollY = Math.max(0, content.getHeight() - viewportHeight);
if (getScrollY() > maxScrollY) {
scrollTo(getScrollX(), maxScrollY);
}
}
private void cancelPendingClamp() {
if (!isClampPending) return;
isClampPending = false;
getViewTreeObserver().removeOnPreDrawListener(clampBeforeDraw);
}
}
@@ -1 +1 @@
{ "platforms": ["android", "ios"], "android": { "modules": [] }, "ios": { "modules": [] } }
{ "platforms": ["android"], "android": { "modules": [] } }
@@ -1,16 +0,0 @@
Pod::Spec.new do |s|
s.name = 'PaseoScroll'
s.version = '0.1.0'
s.summary = 'Scroll view corrections for Paseo'
s.description = 'Scroll view corrections for Paseo'
s.license = 'Apache-2.0'
s.author = 'Paseo'
s.homepage = 'https://paseo.sh'
s.platforms = { :ios => '15.1' }
s.source = { :path => '.' }
s.static_framework = true
s.source_files = "**/*.{h,mm}"
install_modules_dependencies(s)
end
@@ -1,148 +0,0 @@
#import <React/RCTComponentViewFactory.h>
#import <React/RCTCustomPullToRefreshViewProtocol.h>
#import <React/RCTScrollViewComponentView.h>
#import <React/RCTViewComponentView.h>
#import <react/renderer/components/scrollview/ScrollViewProps.h>
#import <react/renderer/components/scrollview/ScrollViewShadowNode.h>
using namespace facebook::react;
/**
* React Native's scroll view with three maintainVisibleContentPosition corrections.
*
* An anchor removed by the mount it anchors. React Native picks the anchor before the mount and
* moves the offset by the anchor's movement after it. When the mount removes that view, its frame
* after the mount no longer describes the content, and the offset jumps by an unrelated amount.
* Android keeps a removed view's last frame, so it keeps the offset. This class keeps the offset on
* iOS too.
* The chat timeline hits this when it stops following its end: the same mount removes the end pin
* and opens the disclosure the reader tapped.
* React Native 0.88 skips the adjustment for a removed anchor
* (https://github.com/react/react-native/pull/57294). Its view-registry tag check matches the check
* here; its superview check also skips anchors that removeClippedSubviews detached
* (https://github.com/react/react-native/issues/58910). Remove this part once the installed React
* Native includes the tag check.
*
* The chat timeline's end pin during a drag. While the timeline follows its end, a zero-height view
* at the bottom of the content (native ID "paseo-end-pin", packages/app/src/agent-stream/
* native-end-pin.tsx) is the anchor, so every line that mounts scrolls the content by its height.
* A drag owns the offset from the frame it begins, and JS removes the pin only a round trip later.
* While the user drags, a mount anchored on the pin keeps the offset, so the line grows below the
* rows under the finger. Android's PaseoScrollView does the same.
*
* A shrink at the end. A mount that shrinks the content sets the smaller contentSize, and UIKit
* clamps contentOffset to the new end. React Native then adds the anchor's movement to the clamped
* offset, so the shrink is applied twice and the viewport lands above the content it kept.
* The chat timeline hits this when a finished turn folds while the end pin follows the latest row.
* https://github.com/react/react-native/issues/58578
* Remove once https://github.com/react/react-native/pull/58695 ships; it keeps the pre-mount
* offset and clamps after the adjustment, as this class does.
*/
@interface PaseoScrollViewComponentView : RCTScrollViewComponentView
@end
@implementation PaseoScrollViewComponentView {
CGPoint _offsetBeforeMount;
__weak UIView *_anchorBeforeMount;
NSInteger _anchorTagBeforeMount;
}
// Replaces React Native's class for the ScrollView component. The factory looks a component up the
// first time it mounts, so the registration has to come before the first surface.
+ (void)load
{
[[RCTComponentViewFactory currentComponentViewFactory] registerComponentViewClass:self];
}
- (void)mountingTransactionWillMount:(const MountingTransaction &)transaction
withSurfaceTelemetry:(const SurfaceTelemetry &)surfaceTelemetry
{
_offsetBeforeMount = self.scrollView.contentOffset;
_anchorBeforeMount = [self visibleContentAnchor];
_anchorTagBeforeMount = _anchorBeforeMount.tag;
[super mountingTransactionWillMount:transaction withSurfaceTelemetry:surfaceTelemetry];
}
- (void)mountingTransactionDidMount:(const MountingTransaction &)transaction
withSurfaceTelemetry:(const SurfaceTelemetry &)surfaceTelemetry
{
// The view registry sets a removed view's tag to 0; a released view reads nil here.
BOOL isAnchorRemoved = _anchorTagBeforeMount != 0 && _anchorBeforeMount.tag != _anchorTagBeforeMount;
if (isAnchorRemoved || (self.scrollView.isDragging && [self isEndPin:_anchorBeforeMount])) {
// Without the content-position props, React Native's mount completes without moving the offset.
SharedViewProps props = _props;
_props = ScrollViewShadowNode::defaultSharedProps();
[super mountingTransactionDidMount:transaction withSurfaceTelemetry:surfaceTelemetry];
_props = props;
return;
}
if (![self didClampOffsetDuringMount]) {
[super mountingTransactionDidMount:transaction withSurfaceTelemetry:surfaceTelemetry];
return;
}
UIScrollView *scrollView = self.scrollView;
scrollView.contentOffset = _offsetBeforeMount;
[super mountingTransactionDidMount:transaction withSurfaceTelemetry:surfaceTelemetry];
CGFloat y = MAX(MIN(scrollView.contentOffset.y, [self maxOffsetY]), -scrollView.adjustedContentInset.top);
if (y != scrollView.contentOffset.y) {
scrollView.contentOffset = CGPointMake(scrollView.contentOffset.x, y);
}
}
// UIKit moved the offset to the new end while the mount set a smaller contentSize. A user scroll
// never gets here: React Native keeps the offset through contentSize changes while the user scrolls.
- (BOOL)didClampOffsetDuringMount
{
const auto &props = static_cast<const ScrollViewProps &>(*_props);
if (!props.maintainVisibleContentPosition) {
return NO;
}
CGFloat y = self.scrollView.contentOffset.y;
return y < _offsetBeforeMount.y - 0.5 && ABS(y - [self maxOffsetY]) <= 0.5;
}
// The view React Native anchors on for vertical content: the first content child from
// minIndexForVisible that reaches into the viewport, or the last one.
- (UIView *)visibleContentAnchor
{
const auto &props = static_cast<const ScrollViewProps &>(*_props);
UIScrollView *scrollView = self.scrollView;
if (!props.maintainVisibleContentPosition || scrollView.contentSize.width > self.frame.size.width) {
return nil;
}
NSArray<UIView *> *children = [self contentView].subviews;
NSUInteger first = props.maintainVisibleContentPosition.value().minIndexForVisible;
for (NSUInteger index = first; index < children.count; ++index) {
UIView *child = children[index];
if (CGRectGetMaxY(child.frame) > scrollView.contentOffset.y || index == children.count - 1) {
return child;
}
}
return nil;
}
- (BOOL)isEndPin:(UIView *)view
{
return [view isKindOfClass:[RCTViewComponentView class]] &&
[((RCTViewComponentView *)view).nativeId isEqualToString:@"paseo-end-pin"];
}
- (UIView *)contentView
{
for (UIView *child in self.containerView.subviews) {
if (![child conformsToProtocol:@protocol(RCTCustomPullToRefreshViewProtocol)]) {
return child;
}
}
return nil;
}
- (CGFloat)maxOffsetY
{
UIScrollView *scrollView = self.scrollView;
return MAX(
scrollView.contentSize.height - scrollView.bounds.size.height + scrollView.adjustedContentInset.bottom,
-scrollView.adjustedContentInset.top);
}
@end
@@ -1,4 +0,0 @@
{
"platforms": ["ios"],
"ios": { "modules": ["PaseoWindowLayoutModule"] }
}
@@ -1,15 +0,0 @@
Pod::Spec.new do |s|
s.name = 'PaseoWindowLayout'
s.version = '0.1.0'
s.summary = 'System window layout geometry for Paseo'
s.description = 'Publishes system bar and division geometry'
s.license = 'Apache-2.0'
s.author = 'Paseo'
s.homepage = 'https://paseo.sh'
s.platforms = { :ios => '16.4' }
s.swift_version = '5.4'
s.source = { :path => '.' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.source_files = '**/*.swift'
end
@@ -1,110 +0,0 @@
import ExpoModulesCore
import UIKit
public final class PaseoWindowLayoutModule: Module {
public func definition() -> ModuleDefinition {
Name("PaseoWindowLayout")
View(WindowLayoutView.self) {
Events("onGeometryChange")
}
}
}
final class WindowLayoutView: ExpoView {
let onGeometryChange = EventDispatcher()
private var updateScheduled = false
private var previousPayload: [String: Any]?
private var divisions: [CGRect] = []
private var barGuide: UILayoutGuide?
private var barEdge = "none"
private var measuredBounds = CGRect.zero
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
isUserInteractionEnabled = false
#if compiler(>=6.4)
if #available(iOS 27.1, *) {
registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) {
(view: WindowLayoutView, _) in
view.setNeedsUpdateConstraints()
view.scheduleUpdate()
}
}
#endif
}
override func didMoveToWindow() {
super.didMoveToWindow()
previousPayload = nil
setNeedsUpdateConstraints()
scheduleUpdate()
}
override func safeAreaInsetsDidChange() {
super.safeAreaInsetsDidChange()
setNeedsUpdateConstraints()
scheduleUpdate()
}
override func layoutSubviews() {
super.layoutSubviews()
if bounds != measuredBounds {
measuredBounds = bounds
setNeedsUpdateConstraints()
}
scheduleUpdate()
}
override func updateConstraints() {
#if compiler(>=6.4)
if #available(iOS 27.1, *) {
// UIKit tracks these reads and invalidates constraints when regions change.
divisions = reservedRegions(kind: .division).compactMap { $0.isActive ? $0.frame : nil }
let edge: String
switch traitCollection.verticalBarEdge {
case .leading: edge = "leading"
case .trailing: edge = "trailing"
default: edge = "none"
}
if edge != barEdge {
barEdge = edge
barGuide = edge == "none" ? nil : layoutGuide(
for: .bar(onEdge: edge == "trailing" ? .right : .left, extent: 44)
)
}
}
#endif
super.updateConstraints()
scheduleUpdate()
}
private func scheduleUpdate() {
guard !updateScheduled else { return }
updateScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.updateScheduled = false
self.publish()
}
}
private func publish() {
guard window != nil, bounds.width > 0, bounds.height > 0 else { return }
let bar = barGuide?.layoutFrame
var barPayload: Any = NSNull()
if let bar, bar.width > 0, bar.height > 0 { barPayload = rect(bar) }
let payload: [String: Any] = [
"bounds": rect(bounds),
"verticalBarEdge": barEdge,
"verticalBar": barPayload,
"divisions": divisions.map(rect)
]
guard !NSDictionary(dictionary: payload).isEqual(to: previousPayload ?? [:]) else { return }
previousPayload = payload
onGeometryChange(payload)
}
private func rect(_ frame: CGRect) -> [String: CGFloat] {
["x": frame.minX, "y": frame.minY, "width": frame.width, "height": frame.height]
}
}
-1
View File
@@ -59,7 +59,6 @@
"@getpaseo/protocol": "*",
"@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14",
"@legendapp/list": "^3.7.1",
"@marijn/find-cluster-break": "^1.0.2",
"@mattermost/react-native-paste-input": "2.0.1",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -263,7 +263,7 @@ describe("bottom anchor controller driver", () => {
expect(harness.scrollAttempts).toHaveLength(0);
harness.driver.endUserScroll({ isAtEnd: true });
harness.driver.endUserScroll({ isNearBottom: true });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
@@ -293,7 +293,7 @@ describe("bottom anchor controller driver", () => {
});
harness.context.authoritativeReady = true;
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
harness.driver.endUserScroll({ isAtEnd: true });
harness.driver.endUserScroll({ isNearBottom: true });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
@@ -321,7 +321,7 @@ describe("bottom anchor controller driver", () => {
nextIsNearBottom: false,
scrollDelta: 48,
});
harness.driver.endUserScroll({ isAtEnd: false });
harness.driver.endUserScroll({ isNearBottom: false });
harness.context.authoritativeReady = true;
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
harness.driver.reevaluate();
@@ -340,7 +340,10 @@ describe("bottom anchor controller driver", () => {
const harness = createDriverHarness();
harness.driver.detachByUser();
harness.driver.handleContentSizeChange();
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1500,
});
harness.driver.handleViewportMetricsChange({
previousViewportWidth: 800,
viewportWidth: 640,
@@ -353,9 +356,7 @@ describe("bottom anchor controller driver", () => {
expect(harness.scrollAttempts).toHaveLength(0);
});
// While following, the list keeps its end through every content change, so a drag that kept
// following would see each streamed line move the rows under the finger.
it("stops following while a user scroll owns the viewport", () => {
it("pauses sticky maintenance while a user scroll owns the viewport", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
@@ -363,8 +364,12 @@ describe("bottom anchor controller driver", () => {
},
});
harness.driver.prepareForStickyContentChange();
harness.driver.beginUserScroll();
harness.driver.handleContentSizeChange();
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1400,
});
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
@@ -372,29 +377,19 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(0);
expect(harness.driver.getSnapshot().mode).toBe("detached");
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
pendingVerification: null,
});
harness.driver.endUserScroll({ isAtEnd: false });
harness.driver.endUserScroll({ isNearBottom: false });
expect(harness.driver.getSnapshot().mode).toBe("detached");
});
// Resuming would scroll by the distance left to the end in one frame, right after the release.
it("stays detached when a user scroll ends near the end but short of it", () => {
const harness = createDriverHarness();
harness.driver.beginUserScroll();
harness.context.nearBottom = true;
harness.driver.handleContentSizeChange();
harness.driver.endUserScroll({ isAtEnd: false });
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot().mode).toBe("detached");
expect(harness.scrollAttempts).toHaveLength(0);
});
it("resumes following when a user scroll ends at the end", () => {
it("restores sticky maintenance when a user scroll returns to the bottom", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
@@ -408,13 +403,16 @@ describe("bottom anchor controller driver", () => {
nextIsNearBottom: false,
scrollDelta: 48,
});
harness.driver.handleContentSizeChange();
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1400,
});
harness.context.nearBottom = true;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: true,
scrollDelta: -48,
});
harness.driver.endUserScroll({ isAtEnd: true });
harness.driver.endUserScroll({ isNearBottom: true });
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot()).toMatchObject({
@@ -422,55 +420,7 @@ describe("bottom anchor controller driver", () => {
pendingRequest: null,
pendingVerification: null,
});
// Following stopped for the gesture, so content that arrived meanwhile left the end below it.
expect(harness.scrollAttempts).toEqual([false]);
});
it("holds a disclosure detached and resumes following when it is released at the end", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
});
harness.driver.holdForDisclosure();
harness.driver.handleContentSizeChange();
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: true,
scrollDelta: -12,
});
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot().mode).toBe("detached");
expect(harness.scrollAttempts).toHaveLength(0);
harness.driver.releaseDisclosure({ isAtEnd: true });
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
});
it("stays detached when a disclosure is released away from the end", () => {
const harness = createDriverHarness();
harness.driver.holdForDisclosure();
harness.driver.releaseDisclosure({ isAtEnd: false });
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot().mode).toBe("detached");
expect(harness.scrollAttempts).toHaveLength(0);
});
it("leaves a disclosure release to a user scroll that owns the viewport", () => {
const harness = createDriverHarness();
harness.driver.holdForDisclosure();
harness.driver.beginUserScroll();
harness.driver.releaseDisclosure({ isAtEnd: true });
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot().mode).toBe("detached");
expect(harness.scrollAttempts).toHaveLength(1);
});
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
@@ -491,7 +441,7 @@ describe("bottom anchor controller driver", () => {
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
});
it("scrolls for a viewport resize while following", () => {
it("schedules sticky maintenance on viewport and content growth", () => {
const harness = createDriverHarness();
harness.driver.handleViewportMetricsChange({
@@ -502,44 +452,13 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
});
it("never scrolls for a content change while following", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1600,
});
harness.context.measurementState.contentHeight = 1600;
harness.driver.handleContentSizeChange();
harness.context.measurementState.contentHeight = 1250;
harness.driver.handleContentSizeChange();
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(0);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
pendingVerification: null,
});
});
it("detaches when the user scrolls away after a content change", () => {
const harness = createDriverHarness();
harness.context.measurementState.contentHeight = 1600;
harness.driver.handleContentSizeChange();
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 4,
});
expect(harness.driver.getSnapshot().mode).toBe("detached");
expect(harness.scrollAttempts).toHaveLength(0);
expect(harness.scrollAttempts).toHaveLength(2);
});
it("keeps a pending request blocked when stale container measurements arrive", () => {
@@ -637,7 +556,10 @@ describe("bottom anchor controller driver", () => {
harness.context.measurementState.contentHeight = 14804;
harness.context.nearBottom = false;
harness.driver.handleContentSizeChange();
harness.driver.handleContentSizeChange({
previousContentHeight: 14322,
contentHeight: 14804,
});
harness.scheduler.flushFrame();
@@ -706,7 +628,10 @@ describe("bottom anchor controller driver", () => {
harness.context.measurementState.contentHeight = 16230;
harness.context.nearBottom = false;
harness.driver.handleContentSizeChange();
harness.driver.handleContentSizeChange({
previousContentHeight: 14322,
contentHeight: 16230,
});
harness.scheduler.flushFrame();
harness.scheduler.flushFrame();
@@ -760,6 +685,136 @@ describe("bottom anchor controller driver", () => {
expect(harness.driver.getSnapshot().mode).toBe("detached");
});
it("keeps sticky-bottom during streaming growth until bottom is re-verified", () => {
const harness = createDriverHarness();
harness.context.nearBottom = false;
harness.setScrollToBottomBehavior(() => {
harness.context.measurementState.offsetY = 900;
});
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1400,
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(4);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
pendingVerification: null,
});
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 0,
});
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
harness.context.nearBottom = true;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: true,
scrollDelta: 0,
});
harness.scheduler.flushAll();
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 64,
});
expect(harness.driver.getSnapshot().mode).toBe("detached");
});
it("keeps initial native content growth anchored before layout scroll events arrive", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
measurementState: createMeasurementState({
containerKey: "native-virtualized",
viewportWidth: 0,
viewportHeight: 0,
contentHeight: 0,
offsetY: 0,
viewportMeasuredForKey: null,
contentMeasuredForKey: null,
}),
});
harness.setScrollToBottomBehavior(() => {
harness.context.measurementState.offsetY = 0;
});
harness.context.measurementState.contentHeight = 1348;
harness.context.measurementState.contentMeasuredForKey = "native-virtualized";
harness.driver.handleContentSizeChange({
previousContentHeight: 0,
contentHeight: 1348,
});
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingVerification: {
requestId: null,
},
});
harness.context.measurementState.viewportWidth = 390;
harness.context.measurementState.viewportHeight = 546;
harness.context.measurementState.viewportMeasuredForKey = "native-virtualized";
harness.context.measurementState.offsetY = 50;
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 50,
});
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
});
it("keeps native sticky content changes anchored when measured height is unchanged", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
measurementState: createMeasurementState({
containerKey: "native-virtualized",
viewportWidth: 390,
viewportHeight: 546,
contentHeight: 546,
offsetY: 0,
viewportMeasuredForKey: "native-virtualized",
contentMeasuredForKey: "native-virtualized",
}),
});
harness.setScrollToBottomBehavior(() => {
harness.context.measurementState.offsetY = 0;
harness.context.nearBottom = true;
});
harness.driver.prepareForStickyContentChange();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingVerification: {
requestId: null,
},
});
harness.context.measurementState.offsetY = 50;
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 50,
});
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
});
});
describe("controller helper predicates", () => {
@@ -809,6 +864,13 @@ describe("controller helper predicates", () => {
it("suppresses auto-anchor helpers while detached", () => {
const mode: BottomAnchorMode = "detached";
expect(
__private__.shouldRestickOnContentChange({
mode,
previousContentHeight: 1000,
contentHeight: 1100,
}),
).toBe(false);
expect(
__private__.shouldRestickOnViewportChange({
mode,
@@ -69,18 +69,20 @@ interface BottomAnchorControllerDriver {
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
beginUserScroll: () => void;
endUserScroll: (params: { isAtEnd: boolean }) => void;
endUserScroll: (params: { isNearBottom: boolean }) => void;
detachByUser: () => void;
holdForDisclosure: () => void;
releaseDisclosure: (params: { isAtEnd: boolean }) => void;
handleViewportMetricsChange: (params: {
previousViewportWidth: number;
viewportWidth: number;
previousViewportHeight: number;
viewportHeight: number;
}) => void;
handleContentSizeChange: () => void;
handleContentSizeChange: (params: {
previousContentHeight: number;
contentHeight: number;
}) => void;
prepareForStickyViewportChange: () => void;
prepareForStickyContentChange: () => void;
handleScrollNearBottomChange: (params: {
nextIsNearBottom: boolean;
scrollDelta: number;
@@ -413,7 +415,6 @@ function createBottomAnchorControllerDriver(
| "content_size_change"
| "scroll_near_bottom_change"
| "user_scroll_end"
| "disclosure_released"
| "history_readiness_change"
| "manual_reevaluate"
| "retry_scroll",
@@ -504,19 +505,13 @@ function createBottomAnchorControllerDriver(
requestLocalAnchor(request) {
createRequest(request);
},
// The gesture owns the viewport, so following stops until it ends: content growing at the end
// must not move the rows under the finger. The gesture's end decides whether following resumes.
beginUserScroll() {
isUserScrollActive = true;
cancelPendingAttempt();
setModeInternal("detached");
},
// Following resumes only for a gesture that ends at the end. Resuming from short of it would
// scroll the rows the finger just let go of, so a release above the end, or with output that
// arrived below it during the gesture, stays detached with the pill.
endUserScroll(params) {
isUserScrollActive = false;
if (params.isAtEnd) {
if (params.isNearBottom) {
if (mode === "detached") {
setModeInternal("sticky-bottom");
pendingVerification = { requestId: null, retries: 0 };
@@ -538,8 +533,9 @@ function createBottomAnchorControllerDriver(
markStickyMeasurementVerified();
return;
}
cancelPendingRequest("user_scrolled_away");
setModeInternal("detached");
if (mode === "sticky-bottom") {
this.detachByUser();
}
},
detachByUser() {
if (mode === "detached") {
@@ -548,21 +544,6 @@ function createBottomAnchorControllerDriver(
cancelPendingRequest("user_scrolled_away");
setModeInternal("detached");
},
// A disclosure keeps its trigger in place, so following output stops while it applies.
holdForDisclosure() {
cancelPendingRequest("disclosure");
setModeInternal("detached");
},
// Following resumes only if the applied change left the list at its end. Scroll events
// during the hold never re-stick: before the change lands the list is still at the end.
releaseDisclosure(params) {
if (isUserScrollActive || mode !== "detached" || !params.isAtEnd) {
return;
}
setModeInternal("sticky-bottom");
pendingVerification = { requestId: null, retries: 0 };
evaluate(false, "disclosure_released");
},
handleViewportMetricsChange(params) {
if (
params.previousViewportWidth !== params.viewportWidth ||
@@ -587,13 +568,30 @@ function createBottomAnchorControllerDriver(
evaluate(false, "viewport_change");
}
},
// The list keeps its own end through content changes while following, so a change in content
// height never scrolls from here. It only gives a pending jump another look at the new geometry.
handleContentSizeChange() {
if (isUserScrollActive || !pendingRequest) {
handleContentSizeChange(params) {
if (params.previousContentHeight !== params.contentHeight) {
markStickyMeasurementChanged();
}
if (isUserScrollActive) {
return;
}
evaluate(false, "content_size_change");
const shouldRestick = __private__.shouldRestickOnContentChange({
mode,
previousContentHeight: params.previousContentHeight,
contentHeight: params.contentHeight,
});
if (shouldRestick && !pendingRequest) {
pendingVerification = { requestId: null, retries: 0 };
if (attemptHandle) {
input.cancelFrame(attemptHandle);
attemptHandle = null;
}
runAttempt(false);
return;
}
if (shouldRestick || pendingRequest) {
evaluate(false, "content_size_change");
}
},
prepareForStickyViewportChange() {
if (mode !== "sticky-bottom") {
@@ -601,6 +599,25 @@ function createBottomAnchorControllerDriver(
}
markStickyMeasurementChanged();
},
prepareForStickyContentChange() {
if (mode !== "sticky-bottom") {
return;
}
markStickyMeasurementChanged();
if (isUserScrollActive) {
return;
}
if (!pendingRequest) {
pendingVerification = { requestId: null, retries: 0 };
if (attemptHandle) {
input.cancelFrame(attemptHandle);
attemptHandle = null;
}
runAttempt(false);
return;
}
evaluate(false, "content_size_change");
},
handleScrollNearBottomChange(params) {
const { nextIsNearBottom, scrollDelta } = params;
if (isUserScrollActive) {
@@ -678,6 +695,13 @@ export const __private__ = {
input.previousViewportWidth !== input.viewportWidth))
);
},
shouldRestickOnContentChange(input: {
mode: BottomAnchorMode;
previousContentHeight: number;
contentHeight: number;
}): boolean {
return input.mode === "sticky-bottom" && input.contentHeight > input.previousContentHeight;
},
shouldDetachFromScrollAway(input: {
mode: BottomAnchorMode;
nextIsNearBottom: boolean;
@@ -768,18 +792,12 @@ export function useBottomAnchorController(input: {
beginUserScroll() {
driverRef.current?.beginUserScroll();
},
endUserScroll(params: { isAtEnd: boolean }) {
endUserScroll(params: { isNearBottom: boolean }) {
driverRef.current?.endUserScroll(params);
},
detachByUser() {
driverRef.current?.detachByUser();
},
holdForDisclosure() {
driverRef.current?.holdForDisclosure();
},
releaseDisclosure(params: { isAtEnd: boolean }) {
driverRef.current?.releaseDisclosure(params);
},
handleViewportLayout() {},
handleViewportMetricsChange(params: {
previousViewportWidth: number;
@@ -789,12 +807,15 @@ export function useBottomAnchorController(input: {
}) {
driverRef.current?.handleViewportMetricsChange(params);
},
handleContentSizeChange() {
driverRef.current?.handleContentSizeChange();
handleContentSizeChange(params: { previousContentHeight: number; contentHeight: number }) {
driverRef.current?.handleContentSizeChange(params);
},
prepareForStickyViewportChange() {
driverRef.current?.prepareForStickyViewportChange();
},
prepareForStickyContentChange() {
driverRef.current?.prepareForStickyContentChange();
},
handleScrollNearBottomChange(params: { nextIsNearBottom: boolean; scrollDelta: number }) {
driverRef.current?.handleScrollNearBottomChange(params);
},
@@ -24,9 +24,7 @@ function viewport(root: HTMLElement, capture: Capture) {
const handle: StreamViewportHandle = {
scrollToBottom() {},
prepareForViewportChange() {},
holdRow(_rowId, apply) {
apply();
},
holdReadingPosition() {},
scrollToMessage(_messageId, occurrence) {
if (occurrence) capture.occurrence = occurrence;
},
@@ -265,7 +265,7 @@ describe("useChatOutline", () => {
const viewport: StreamViewportHandle = {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
holdRow: vi.fn(),
holdReadingPosition: vi.fn(),
scrollToMessage,
};
const viewportRef = { current: viewport };
@@ -317,7 +317,7 @@ describe("useChatOutline", () => {
current: {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
holdRow: vi.fn(),
holdReadingPosition: vi.fn(),
scrollToMessage,
},
};
@@ -373,7 +373,7 @@ describe("useChatOutline", () => {
const viewport: StreamViewportHandle = {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
holdRow: vi.fn(),
holdReadingPosition: vi.fn(),
scrollToMessage,
};
const livePrompt = {
@@ -1,40 +0,0 @@
import { createContext, useContext, useMemo, type ReactNode, type RefObject } from "react";
import type { 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.
*/
export type DisclosureHold = (apply: () => void) => void;
const applyInPlace: DisclosureHold = (apply) => apply();
const DisclosureHoldContext = createContext<DisclosureHold>(applyInPlace);
export function DisclosureRow({
rowId,
viewportRef,
children,
}: {
rowId: string;
viewportRef: RefObject<StreamViewportHandle | null>;
children: ReactNode;
}) {
const hold = useMemo<DisclosureHold>(
() => (apply) => {
const viewport = viewportRef.current;
if (viewport) {
viewport.holdRow(rowId, apply);
} else {
apply();
}
},
[rowId, viewportRef],
);
return <DisclosureHoldContext.Provider value={hold}>{children}</DisclosureHoldContext.Provider>;
}
/** Call with the toggle's state change: `hold(() => setExpanded(!expanded))`. */
export function useDisclosureHold(): DisclosureHold {
return useContext(DisclosureHoldContext);
}
@@ -3,13 +3,32 @@ import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import { layoutStream } from "./layout";
import type { StreamStrategy } from "./strategy";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
function strategyFor(platform: "web" | "native"): StreamStrategy {
return resolveStreamRenderStrategy({
platform: platform === "native" ? "android" : "web",
isMobileBreakpoint: false,
});
const inverted = platform === "native";
const neighborStep = inverted ? 1 : -1;
const getHistoryBoundaryIndex = (items: StreamItem[]) => {
if (items.length === 0) return null;
return inverted ? 0 : items.length - 1;
};
const getLiveHeadBoundaryIndex = (items: StreamItem[]) => {
if (items.length === 0) return null;
return inverted ? items.length - 1 : 0;
};
return {
orderTail: (items) => (inverted ? [...items].toReversed() : items),
orderHead: (items) => (inverted ? [...items].toReversed() : items),
getNeighborIndex: (index, relation) =>
relation === "above" ? index + neighborStep : index - neighborStep,
getNeighborItem: (items, index, relation) => {
const neighborIndex = relation === "above" ? index + neighborStep : index - neighborStep;
return items[neighborIndex];
},
getHistoryLiveBoundaryIndex: getHistoryBoundaryIndex,
getLiveHeadHistoryBoundaryIndex: getLiveHeadBoundaryIndex,
getLatestItemIndex: getHistoryBoundaryIndex,
getFrameChildOrder: () => (inverted ? "footer-then-content" : "content-then-footer"),
} as StreamStrategy;
}
function userMessage(id: string): Extract<StreamItem, { kind: "user_message" }> {
+1 -1
View File
@@ -289,7 +289,7 @@ describe("layoutStream", () => {
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(assistantRow.completedFooter?.itemId).toBe(assistant.id);
expect(assistantRow.belowItem?.id).toBe("u2");
expect(assistantRow.frameOrder).toBe("content-then-footer");
expect(assistantRow.frameOrder).toBe("footer-then-content");
});
it("keeps an accepted steer inline while its canonical turn is active", () => {
@@ -1,171 +0,0 @@
import { LegendList, type LegendListRef } from "@legendapp/list/react-native";
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";
// 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.
// The native build applies its scroll adjustments through native scroll props that
// react-native-web ignores, so these tests read the offset LegendList holds, not the DOM's.
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const VIEWPORT_HEIGHT = 100;
interface Row {
id: string;
height: number;
}
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
host = document.createElement("div");
host.style.cssText = `display:flex;flex-direction:column;width:300px;height:${VIEWPORT_HEIGHT}px`;
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(() => {
act(() => root.unmount());
grownHeights.clear();
host.remove();
});
function wait(ms: number): Promise<void> {
return act(() => new Promise<void>((resolve) => setTimeout(resolve, ms)));
}
const LIST_STYLE = { flex: 1 };
const rowStyles = new Map<number, { height: number }>();
function rowStyle(height: number) {
let style = rowStyles.get(height);
if (!style) {
style = { height };
rowStyles.set(height, style);
}
return style;
}
// A row that changes its own height without a data change, like a todo card toggling its details.
const grownHeights = new Map<string, number>();
const heightListeners = new Set<() => void>();
function subscribeToHeights(listener: () => void) {
heightListeners.add(listener);
return () => heightListeners.delete(listener);
}
function growRow(id: string, height: number) {
grownHeights.set(id, height);
for (const listener of heightListeners) listener();
}
function RowView({ row }: { row: Row }) {
const height = useSyncExternalStore(
subscribeToHeights,
() => grownHeights.get(row.id) ?? row.height,
);
return <View style={rowStyle(height)} />;
}
function renderRow({ item }: { item: Row }) {
return <RowView row={item} />;
}
function holdOnly(heldRowId: string) {
return {
data: true,
size: true,
shouldRestorePosition: (item: Row) => item.id === heldRowId,
};
}
function keyExtractor(item: Row) {
return item.id;
}
function mountList(heldRowId: string) {
const listRef = React.createRef<LegendListRef>();
const maintainVisibleContentPosition = holdOnly(heldRowId);
const render = (data: Row[]) =>
act(() =>
root.render(
<LegendList
ref={listRef}
data={data}
renderItem={renderRow}
keyExtractor={keyExtractor}
estimatedItemSize={40}
recycleItems={false}
scrollEventThrottle={16}
maintainVisibleContentPosition={maintainVisibleContentPosition}
style={LIST_STYLE}
/>,
),
);
const scrollElement = () => {
const scroller = [...host.querySelectorAll<HTMLElement>("div")].find(
(element) => element.scrollHeight > element.clientHeight + 1,
);
if (!scroller) throw new Error("The list has no scroll element");
return scroller;
};
// Small steps: a large jump suspends LegendList's anchoring until the rows re-measure.
const scrollTo = async (offset: number) => {
for (let step = 20; step <= offset; step += 20) {
scrollElement().scrollTop = step;
await wait(150);
}
};
const heldRowTop = () => {
const state = listRef.current?.getState();
const position = state?.positionByKey(heldRowId);
if (state === undefined || position === undefined) {
throw new Error("The held row is not laid out");
}
return position - state.scroll;
};
return { render, scrollTo, heldRowTop };
}
it("anchors a data change on a held row whose top is above the viewport", async () => {
// The held row is a turn host whose prompt bubble is cut off at the top of the viewport, with a
// fully visible row below it. LegendList's ids in view start at that fully visible row.
const rows: Row[] = Array.from({ length: 10 }, (_, index) => ({
id: `row-${index}`,
height: index === 3 ? 60 : 40,
}));
const list = mountList("row-3");
list.render(rows);
await wait(150);
await list.scrollTo(140);
expect(list.heldRowTop()).toBe(-20);
const inserted: Row = { id: "inserted", height: 30 };
list.render([...rows.slice(0, 2), inserted, ...rows.slice(2)]);
await wait(150);
expect(list.heldRowTop()).toBe(-20);
});
it("anchors a size change on a held row whose top is above the viewport", async () => {
// The same cut-off row grows from its own state, so LegendList sees a size change only.
const rows: Row[] = Array.from({ length: 10 }, (_, index) => ({
id: `row-${index}`,
height: index === 3 ? 60 : 40,
}));
const list = mountList("row-3");
list.render(rows);
await wait(150);
await list.scrollTo(140);
expect(list.heldRowTop()).toBe(-20);
act(() => growRow("row-3", 260));
await wait(150);
expect(list.heldRowTop()).toBe(-20);
});
+4 -4
View File
@@ -47,7 +47,7 @@ describe("buildAgentStreamRenderModel", () => {
historyStart: 2,
});
expect(model.history.map((item) => item.id)).toEqual(["u2", "a2", "u3", "a3"]);
expect(model.history.map((item) => item.id)).toEqual(["a3", "u3", "a2", "u2"]);
expect(model.segments.historyVirtualized).toHaveLength(0);
});
@@ -98,7 +98,7 @@ describe("buildAgentStreamRenderModel", () => {
historyStart,
});
expect(model.history.map((item) => item.id)).toEqual(["visible-u", "visible-a"]);
expect(model.history.map((item) => item.id)).toEqual(["visible-a", "visible-u"]);
});
it("keeps head separate from committed history on desktop web", () => {
@@ -207,7 +207,7 @@ describe("buildAgentStreamRenderModel", () => {
});
});
it("derives the same timing on native", () => {
it("derives the same timing for native inverted rendering", () => {
const tail = [userMessage("u1", 1), assistantMessage("a1", 4)];
const model = buildAgentStreamRenderModel({
@@ -219,7 +219,7 @@ describe("buildAgentStreamRenderModel", () => {
isMobileBreakpoint: false,
});
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["u1", "a1"]);
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
expect(model.turnTiming.byItemId.get("a1")).toEqual({
completedAt: tail[1]?.timestamp,
durationMs: 3000,
@@ -1,105 +0,0 @@
import { describe, expect, it } from "vitest";
import { createNativeDisclosureHold } from "./native-disclosure-hold";
function createFrames() {
let nextId = 1;
const pending = new Map<number, () => void>();
return {
requestFrame: (callback: () => void) => {
const id = nextId++;
pending.set(id, callback);
return id;
},
cancelFrame: (id: number) => {
pending.delete(id);
},
runFrame: () => {
const callbacks = [...pending.values()];
pending.clear();
for (const callback of callbacks) {
callback();
}
},
};
}
function createHarness() {
const frames = createFrames();
const releases: string[] = [];
const hold = createNativeDisclosureHold({
quietFrames: 2,
requestFrame: frames.requestFrame,
cancelFrame: frames.cancelFrame,
onRelease: () => releases.push("released"),
});
return { frames, hold, releases };
}
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");
frames.runFrame();
expect(hold.heldRowId()).toBe("turn-1");
expect(releases).toEqual([]);
frames.runFrame();
expect(hold.heldRowId()).toBeNull();
expect(releases).toEqual(["released"]);
});
it("restarts the quiet frames on every item size change", () => {
const { frames, hold, releases } = createHarness();
hold.hold("turn-1");
frames.runFrame();
hold.noteItemSizeChanged();
frames.runFrame();
expect(releases).toEqual([]);
frames.runFrame();
expect(releases).toEqual(["released"]);
});
it("ignores item size changes outside a hold", () => {
const { frames, hold, releases } = createHarness();
hold.noteItemSizeChanged();
frames.runFrame();
frames.runFrame();
expect(releases).toEqual([]);
});
it("moves the hold to the latest toggled row and releases once", () => {
const { frames, hold, releases } = createHarness();
hold.hold("turn-1");
frames.runFrame();
hold.hold("turn-2");
expect(hold.heldRowId()).toBe("turn-2");
frames.runFrame();
frames.runFrame();
expect(releases).toEqual(["released"]);
});
it("drops the hold without releasing when cancelled", () => {
const { frames, hold, releases } = createHarness();
hold.hold("turn-1");
hold.cancel();
frames.runFrame();
frames.runFrame();
expect(hold.heldRowId()).toBeNull();
expect(releases).toEqual([]);
});
});
@@ -1,64 +0,0 @@
/**
* 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.
*/
export interface NativeDisclosureHold {
heldRowId(): string | null;
hold(rowId: string): void;
noteItemSizeChanged(): void;
cancel(): void;
}
export function createNativeDisclosureHold(input: {
quietFrames: number;
requestFrame: (callback: () => void) => number;
cancelFrame: (id: number) => void;
onRelease: () => void;
}): NativeDisclosureHold {
let heldRowId: string | null = null;
let frameId: number | null = null;
let remainingFrames = 0;
const stopCountdown = () => {
if (frameId !== null) {
input.cancelFrame(frameId);
frameId = null;
}
};
const tick = () => {
remainingFrames -= 1;
if (remainingFrames > 0) {
frameId = input.requestFrame(tick);
return;
}
frameId = null;
heldRowId = null;
input.onRelease();
};
const restartCountdown = () => {
stopCountdown();
remainingFrames = input.quietFrames;
frameId = input.requestFrame(tick);
};
return {
heldRowId: () => heldRowId,
hold(rowId) {
heldRowId = rowId;
restartCountdown();
},
noteItemSizeChanged() {
if (heldRowId !== null) {
restartCountdown();
}
},
cancel() {
stopCountdown();
heldRowId = null;
},
};
}
@@ -1,61 +0,0 @@
import { forwardRef, type ReactNode } from "react";
import {
Animated,
type ScrollView,
type ScrollViewProps,
View,
type ViewStyle,
} from "react-native";
// While the stream follows its end, React Native's native content-position keeping holds a
// zero-height view at the bottom of the content still. It runs on the UI thread around each mount,
// so a row that grows or lands at the end scrolls in the same frame it lays out. LegendList's
// `maintainScrollAtEnd` scrolls from JS after the mount, which paints the growth below the viewport
// for a frame or more first.
//
// The anchor has to be the content's first native child: content-position keeping anchors on the
// first child at or after `minIndexForVisible` that reaches into the viewport, and every child
// LegendList renders can be flattened away. Absolute positioning keeps it out of the flow.
//
// A drag owns the offset from the frame it begins, but the pin stays until JS stops following a
// round trip later. The scroll view subclasses in `modules/paseo-scroll` recognise the pin by its
// native ID and skip its correction while the user drags, so a line mounting in those frames grows
// below the rows under the finger.
const END_PIN_NATIVE_ID = "paseo-end-pin";
const END_ANCHOR_STYLE: ViewStyle = {
position: "absolute",
bottom: 0,
left: 0,
height: 0,
width: 0,
};
const END_ANCHOR_POSITION = { minIndexForVisible: 0 };
interface EndPinnedScrollViewProps extends ScrollViewProps {
pinEnd: boolean;
children?: ReactNode;
}
export const EndPinnedScrollView = forwardRef<ScrollView, EndPinnedScrollViewProps>(
function EndPinnedScrollView({ pinEnd, children, maintainVisibleContentPosition, ...rest }, ref) {
return (
<Animated.ScrollView
{...rest}
ref={ref}
maintainVisibleContentPosition={
pinEnd ? END_ANCHOR_POSITION : maintainVisibleContentPosition
}
>
{pinEnd ? (
<View
collapsable={false}
nativeID={END_PIN_NATIVE_ID}
pointerEvents="none"
style={END_ANCHOR_STYLE}
/>
) : null}
{children}
</Animated.ScrollView>
);
},
);
@@ -4,48 +4,31 @@ import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
describe("native scroll-to-bottom animation", () => {
it("jumps across unmounted history instead of animating through blank rows", () => {
expect(
shouldAnimateNativeScrollToBottom({
distanceFromEnd: 60_000,
viewportHeight: 600,
drawDistance: 2000,
}),
shouldAnimateNativeScrollToBottom({ offsetY: 60_000, viewportHeight: 600, windowSize: 10 }),
).toBe(false);
});
it("keeps near-end movement eligible for animation", () => {
it("keeps near-bottom movement eligible for animation", () => {
expect(
shouldAnimateNativeScrollToBottom({
distanceFromEnd: 300,
viewportHeight: 600,
drawDistance: 2000,
}),
shouldAnimateNativeScrollToBottom({ offsetY: 300, viewportHeight: 600, windowSize: 10 }),
).toBe(true);
});
it("bounds travel by the rows mounted below the viewport", () => {
it("bounds travel by the bottom-side overscan, not the whole window", () => {
expect(
shouldAnimateNativeScrollToBottom({
distanceFromEnd: 1000,
viewportHeight: 600,
drawDistance: 2000,
}),
shouldAnimateNativeScrollToBottom({ offsetY: 2700, viewportHeight: 600, windowSize: 10 }),
).toBe(true);
expect(
shouldAnimateNativeScrollToBottom({
distanceFromEnd: 1001,
viewportHeight: 600,
drawDistance: 2000,
}),
shouldAnimateNativeScrollToBottom({ offsetY: 2701, viewportHeight: 600, windowSize: 10 }),
).toBe(false);
expect(
shouldAnimateNativeScrollToBottom({ offsetY: 1500, viewportHeight: 600, windowSize: 5 }),
).toBe(false);
});
it("does not animate before the viewport has been measured", () => {
expect(
shouldAnimateNativeScrollToBottom({
distanceFromEnd: 0,
viewportHeight: 0,
drawDistance: 2000,
}),
shouldAnimateNativeScrollToBottom({ offsetY: 0, viewportHeight: 0, windowSize: 10 }),
).toBe(false);
});
});
@@ -1,10 +1,10 @@
export function shouldAnimateNativeScrollToBottom(input: {
distanceFromEnd: number;
offsetY: number;
viewportHeight: number;
drawDistance: number;
windowSize: number;
}): boolean {
// Scrolled away from the end, LegendList keeps half its draw distance mounted below the
// viewport. An animated jump farther than that crosses rows it has not rendered.
const mountedBelowViewport = input.drawDistance / 2;
return input.viewportHeight > 0 && input.distanceFromEnd <= mountedBelowViewport;
// VirtualizedList splits the extra window around the viewport. An animated
// jump farther than the bottom-side overscan crosses unmounted history.
const bottomOverscanHeight = ((input.windowSize - 1) / 2) * input.viewportHeight;
return input.viewportHeight > 0 && input.offsetY <= bottomOverscanHeight;
}
@@ -1,44 +0,0 @@
import { useState, useSyncExternalStore } from "react";
/**
* Whether the reader is at the end of the timeline. The viewport writes it and only the
* scroll-to-bottom button reads it, so detaching from the end (a scroll, or a disclosure
* that opens below its trigger) re-renders the button and not the timeline.
*/
export interface NearBottom {
set: (value: boolean) => void;
subscribe: (listener: () => void) => () => void;
get: () => boolean;
}
function createNearBottom(): NearBottom {
let value = true;
const listeners = new Set<() => void>();
return {
set: (next) => {
if (next === value) return;
value = next;
for (const listener of listeners) listener();
},
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
get: () => value,
};
}
/** One per viewed agent; a newly viewed agent starts at the end. */
export function useNearBottom(agentId: string): NearBottom {
const [owner, setOwner] = useState(() => ({ agentId, nearBottom: createNearBottom() }));
if (owner.agentId === agentId) return owner.nearBottom;
const next = { agentId, nearBottom: createNearBottom() };
setOwner(next);
return next.nearBottom;
}
export function useIsNearBottom(nearBottom: NearBottom): boolean {
return useSyncExternalStore(nearBottom.subscribe, nearBottom.get, nearBottom.get);
}
@@ -1,92 +0,0 @@
import { describe, expect, it } from "vitest";
import { createNewRowEstimates } from "./new-row-estimates";
interface Row {
id: string;
kind: string;
}
function row(id: string, kind = "assistant_message"): Row {
return { id, kind };
}
describe("new row estimates", () => {
it("leaves rows of the first list to the list's averages", () => {
const estimates = createNewRowEstimates<Row>();
const first = row("a");
estimates.noteRows([first]);
expect(estimates.estimate(first)).toBeUndefined();
});
it("estimates a new row at the first measured size of the last new row of its kind", () => {
const estimates = createNewRowEstimates<Row>();
estimates.noteRows([row("a")]);
const second = row("b");
estimates.noteRows([row("a"), second]);
expect(estimates.estimate(second)).toBeUndefined();
estimates.noteMeasured(second, 34);
estimates.noteMeasured(second, 56);
const third = row("c");
estimates.noteRows([row("a"), second, third]);
expect(estimates.estimate(third)).toBe(34);
});
it("keeps sizes per kind", () => {
const estimates = createNewRowEstimates<Row>();
estimates.noteRows([]);
const text = row("a");
const tool = row("b", "tool_call");
estimates.noteRows([text, tool]);
estimates.noteMeasured(text, 34);
estimates.noteMeasured(tool, 36);
const nextTool = row("c", "tool_call");
const nextText = row("d");
estimates.noteRows([text, tool, nextTool, nextText]);
expect(estimates.estimate(nextTool)).toBe(36);
expect(estimates.estimate(nextText)).toBe(34);
});
it("records the kind a row has when it is measured", () => {
const estimates = createNewRowEstimates<Row>();
estimates.noteRows([]);
estimates.noteRows([row("a", "thought")]);
estimates.noteMeasured(row("a", "tool_call"), 36);
const thought = row("b", "thought");
const tool = row("c", "tool_call");
estimates.noteRows([row("a", "tool_call"), thought, tool]);
expect(estimates.estimate(thought)).toBeUndefined();
expect(estimates.estimate(tool)).toBe(36);
});
it("estimates new rows anywhere in the list", () => {
const estimates = createNewRowEstimates<Row>();
estimates.noteRows([row("b"), row("d")]);
const appendedTool = row("e", "tool_call");
estimates.noteRows([row("b"), row("d"), appendedTool]);
estimates.noteMeasured(appendedTool, 36);
const older = row("a", "tool_call");
const revealed = row("c", "tool_call");
estimates.noteRows([older, row("b"), revealed, row("d"), appendedTool]);
expect(estimates.estimate(older)).toBe(36);
expect(estimates.estimate(revealed)).toBe(36);
expect(estimates.estimate(row("b", "tool_call"))).toBeUndefined();
});
it("stops estimating a measured row", () => {
const estimates = createNewRowEstimates<Row>();
estimates.noteRows([]);
const first = row("a");
estimates.noteRows([first]);
estimates.noteMeasured(first, 34);
expect(estimates.estimate(first)).toBeUndefined();
});
});
@@ -1,47 +0,0 @@
interface EstimatedRow {
id: string;
kind: string;
}
export interface NewRowEstimates<Row extends EstimatedRow> {
/** Called with every row list; a row whose id the previous list did not have is new. */
noteRows: (rows: readonly Row[]) => void;
/** Estimated size of an unmeasured new row, or undefined to use the list's average for its type. */
estimate: (row: Row) => number | undefined;
/** A row can change kind under the same id (a thought becomes a tool call); its current kind counts. */
noteMeasured: (row: Row, size: number) => void;
}
// A row that enters the list is a streamed row at the end, a tool call an expanded summary reveals,
// or an older row loaded at the start. Its first measured size is close to constant per kind: one
// line of text, a tool call header, a thought line. So a new row is estimated at the first measured
// size of the last new row of its kind, and is laid out near its real size before it is measured.
// The list's average for a type is the size of whole turns and is far off for any of these. Rows of
// the first list are not new; they keep the average until measured.
export function createNewRowEstimates<Row extends EstimatedRow>(): NewRowEstimates<Row> {
let previousRowIds: ReadonlySet<string> | null = null;
const unmeasuredRowIds = new Set<string>();
const firstSizeByKind = new Map<string, number>();
return {
noteRows(rows) {
const rowIds = new Set(rows.map((row) => row.id));
if (previousRowIds) {
for (const row of rows) {
if (!previousRowIds.has(row.id)) unmeasuredRowIds.add(row.id);
}
}
for (const rowId of unmeasuredRowIds) {
if (!rowIds.has(rowId)) unmeasuredRowIds.delete(rowId);
}
previousRowIds = rowIds;
},
estimate(row) {
return unmeasuredRowIds.has(row.id) ? firstSizeByKind.get(row.kind) : undefined;
},
noteMeasured(row, size) {
if (!unmeasuredRowIds.delete(row.id)) return;
firstSizeByKind.set(row.kind, size);
},
};
}
@@ -50,7 +50,6 @@ const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
isLatestTurnOpen: false,
expandedHostIds: new Set(),
getGroupedCalls: () => undefined,
previousTurnsByHostId: new Map(),
});
/** Source messages reach plugins before display projection. */
@@ -135,7 +134,6 @@ export function createStreamPresentation() {
isLatestTurnOpen,
expandedHostIds: input.expandedTurnHostIds,
getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls,
previousTurnsByHostId: collapsed?.turnsByHostId ?? NO_COLLAPSED_TURNS.turnsByHostId,
});
collapsedSource = projection.tail;
collapsedIsLatestTurnOpen = isLatestTurnOpen;
@@ -56,16 +56,6 @@ describe("reading anchor", () => {
).toBe(1100);
});
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]!);
// 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("leaves expansion of the row being read in place", () => {
const anchor = createReadingAnchor();
anchor.reconcile(600, rows);
@@ -33,11 +33,6 @@ 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<RowGeometry, "id" | "top">) {
anchor = { id: row.id, top: row.top };
},
scroll(scrollTop: number) {
const next = readingRow(scrollTop);
anchor = next ? { id: next.id, top: next.top } : null;
@@ -12,6 +12,7 @@ import {
isNearBottomForStreamRenderStrategy,
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
} from "./strategy";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
@@ -63,6 +64,8 @@ describe("resolveStreamRenderStrategy", () => {
});
expect(strategy.shouldUseVirtualizedList()).toBe(false);
expect(strategy.getFlatListInverted()).toBe(false);
expect(strategy.getOverlayScrollbarInverted()).toBe(false);
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(true);
expect(strategy.getBottomAnchorTransportBehavior()).toEqual({
verificationDelayFrames: 0,
@@ -70,27 +73,57 @@ describe("resolveStreamRenderStrategy", () => {
});
});
it("uses a virtualized forward stream on native", () => {
it("uses inverted_stream on native", () => {
const strategy = resolveStreamRenderStrategy({
platform: "ios",
isMobileBreakpoint: false,
});
expect(strategy.shouldUseVirtualizedList()).toBe(true);
expect(strategy.getFlatListInverted()).toBe(true);
expect(strategy.getOverlayScrollbarInverted()).toBe(true);
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(false);
expect(strategy.getBottomAnchorTransportBehavior()).toEqual({
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
});
});
it("delays native verification while viewport settling is in flight", () => {
const strategy = resolveStreamRenderStrategy({
platform: "ios",
isMobileBreakpoint: false,
});
expect(
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
}),
).toEqual({
verificationDelayFrames: 4,
verificationRetryMode: "recheck",
});
});
it("does not inflate forward-stream verification delays during web resize", () => {
const strategy = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
expect(
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
}),
).toEqual({
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
});
});
});
const PLATFORMS = ["web", "ios", "android"] as const;
function strategyFor(platform: (typeof PLATFORMS)[number]) {
return resolveStreamRenderStrategy({ platform, isMobileBreakpoint: false });
}
describe("stream ordering", () => {
const streamItems: StreamItem[] = [
userMessage("u1", "user-1", 1),
@@ -98,8 +131,11 @@ describe("stream ordering", () => {
assistantMessage("a2", "assistant-2", 3),
];
it.each(PLATFORMS)("keeps oldest-to-newest order for tail and head on %s", (platform) => {
const strategy = strategyFor(platform);
it("keeps forward_stream order unchanged for tail and head", () => {
const strategy = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const tail = orderTailForStreamRenderStrategy({ strategy, streamItems });
const head = orderHeadForStreamRenderStrategy({ strategy, streamHead: streamItems });
@@ -107,31 +143,73 @@ describe("stream ordering", () => {
expect(tail.map((item) => item.id)).toEqual(["u1", "a1", "a2"]);
expect(head.map((item) => item.id)).toEqual(["u1", "a1", "a2"]);
});
it("reverses inverted_stream order for tail and head", () => {
const strategy = resolveStreamRenderStrategy({
platform: "android",
isMobileBreakpoint: false,
});
const tail = orderTailForStreamRenderStrategy({ strategy, streamItems });
const head = orderHeadForStreamRenderStrategy({ strategy, streamHead: streamItems });
expect(tail.map((item) => item.id)).toEqual(["a2", "a1", "u1"]);
expect(head.map((item) => item.id)).toEqual(["a2", "a1", "u1"]);
});
});
describe("neighbor and traversal semantics", () => {
it.each(PLATFORMS)("maps above/below indices on %s", (platform) => {
const strategy = strategyFor(platform);
it("maps above/below indices for forward and inverted streams", () => {
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const inverted = resolveStreamRenderStrategy({
platform: "ios",
isMobileBreakpoint: false,
});
expect(getStreamNeighborIndex({ strategy, index: 3, relation: "above" })).toBe(2);
expect(getStreamNeighborIndex({ strategy, index: 3, relation: "below" })).toBe(4);
expect(getStreamNeighborIndex({ strategy: forward, index: 3, relation: "above" })).toBe(2);
expect(getStreamNeighborIndex({ strategy: forward, index: 3, relation: "below" })).toBe(4);
expect(getStreamNeighborIndex({ strategy: inverted, index: 3, relation: "above" })).toBe(4);
expect(getStreamNeighborIndex({ strategy: inverted, index: 3, relation: "below" })).toBe(2);
});
it.each(PLATFORMS)("collects assistant response content on %s", (platform) => {
it("collects assistant response content with strategy traversal direction", () => {
const chronological: StreamItem[] = [
userMessage("u1", "user-1", 1),
assistantMessage("a1", "assistant-1", 2),
assistantMessage("a2", "assistant-2", 3),
userMessage("u2", "user-2", 4),
];
const strategy = strategyFor(platform);
const items = orderTailForStreamRenderStrategy({ strategy, streamItems: chronological });
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const forwardStartIndex = chronological.findIndex((item) => item.id === "a2");
expect(
collectAssistantResponseContentForStreamRenderStrategy({
strategy,
items,
startIndex: items.findIndex((item) => item.id === "a2"),
strategy: forward,
items: chronological,
startIndex: forwardStartIndex,
}),
).toBe("assistant-1\n\nassistant-2");
const inverted = resolveStreamRenderStrategy({
platform: "android",
isMobileBreakpoint: false,
});
const invertedItems = orderTailForStreamRenderStrategy({
strategy: inverted,
streamItems: chronological,
});
const invertedStartIndex = invertedItems.findIndex((item) => item.id === "a2");
expect(
collectAssistantResponseContentForStreamRenderStrategy({
strategy: inverted,
items: invertedItems,
startIndex: invertedStartIndex,
}),
).toBe("assistant-1\n\nassistant-2");
});
@@ -142,7 +220,7 @@ describe("neighbor and traversal semantics", () => {
{ ...toolCall("tool", 2), turnId: "turn-2" },
{ ...assistantMessage("a2", "second turn", 3), turnId: "turn-2" },
];
const strategy = strategyFor("web");
const strategy = resolveStreamRenderStrategy({ platform: "web", isMobileBreakpoint: false });
expect(
collectAssistantResponseContentForStreamRenderStrategy({
@@ -154,17 +232,37 @@ describe("neighbor and traversal semantics", () => {
});
it("returns undefined neighbor when index would be out of bounds", () => {
const strategy = strategyFor("web");
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const items: StreamItem[] = [userMessage("u1", "user-1", 1)];
expect(getStreamNeighborItem({ strategy, items, index: 0, relation: "above" })).toBeUndefined();
expect(getStreamNeighborItem({ strategy, items, index: 0, relation: "below" })).toBeUndefined();
expect(
getStreamNeighborItem({
strategy: forward,
items,
index: 0,
relation: "above",
}),
).toBeUndefined();
expect(
getStreamNeighborItem({
strategy: forward,
items,
index: 0,
relation: "below",
}),
).toBeUndefined();
});
});
describe("scroll/bottom calculations", () => {
it.each(PLATFORMS)("computes near-bottom from the distance to the end on %s", (platform) => {
const strategy = strategyFor(platform);
it("computes near-bottom using forward_stream distance-from-bottom math", () => {
const strategy = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
expect(
isNearBottomForStreamRenderStrategy({
@@ -186,10 +284,48 @@ describe("scroll/bottom calculations", () => {
).toBe(false);
});
it.each(PLATFORMS)("maps scroll-to-bottom to the max offset on %s", (platform) => {
it("computes near-bottom and scroll-to-bottom offset for inverted_stream", () => {
const strategy = resolveStreamRenderStrategy({
platform: "ios",
isMobileBreakpoint: false,
});
expect(
isNearBottomForStreamRenderStrategy({
strategy,
offsetY: 12,
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
}),
).toBe(true);
expect(
isNearBottomForStreamRenderStrategy({
strategy,
offsetY: 40,
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
}),
).toBe(false);
expect(
getBottomOffsetForStreamRenderStrategy({
strategy: strategyFor(platform),
strategy,
viewportHeight: 300,
contentHeight: 1000,
}),
).toBe(0);
});
it("maps scroll-to-bottom to max offset for forward_stream", () => {
const strategy = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
expect(
getBottomOffsetForStreamRenderStrategy({
strategy,
viewportHeight: 320,
contentHeight: 1000,
}),
@@ -198,15 +334,36 @@ describe("scroll/bottom calculations", () => {
});
describe("edge slot semantics", () => {
it.each(PLATFORMS)("uses the footer slot on %s", (platform) => {
it("uses footer slot for forward_stream and header slot for inverted_stream", () => {
const EdgeSlot = () => null;
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const inverted = resolveStreamRenderStrategy({
platform: "android",
isMobileBreakpoint: false,
});
expect(
getStreamEdgeSlotProps({ strategy: strategyFor(platform), component: EdgeSlot, gapSize: 4 }),
).toEqual({
const forwardProps = getStreamEdgeSlotProps({
strategy: forward,
component: EdgeSlot,
gapSize: 4,
});
const invertedProps = getStreamEdgeSlotProps({
strategy: inverted,
component: EdgeSlot,
gapSize: 4,
});
expect(forwardProps).toEqual({
ListFooterComponent: EdgeSlot,
ListFooterComponentStyle: { marginTop: 4 },
});
expect(invertedProps).toEqual({
ListHeaderComponent: EdgeSlot,
ListHeaderComponentStyle: { marginBottom: 4 },
});
});
});
@@ -216,31 +373,81 @@ describe("layout strategy edges", () => {
assistantMessage("a1", "assistant-1", 2),
];
it.each(PLATFORMS)(
"uses the newest history edge as the history/live boundary on %s",
(platform) => {
const strategy = strategyFor(platform);
const history = orderTailForStreamRenderStrategy({ strategy, streamItems });
it("uses the newest history edge as the history/live boundary", () => {
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const inverted = resolveStreamRenderStrategy({
platform: "android",
isMobileBreakpoint: false,
});
expect(getHistoryLiveBoundaryIndexForStreamRenderStrategy({ strategy, history })).toBe(1);
},
);
const forwardHistory = orderTailForStreamRenderStrategy({ strategy: forward, streamItems });
const invertedHistory = orderTailForStreamRenderStrategy({ strategy: inverted, streamItems });
it.each(PLATFORMS)(
"uses the oldest live-head edge as the live-head/history boundary on %s",
(platform) => {
const strategy = strategyFor(platform);
const liveHead = orderHeadForStreamRenderStrategy({ strategy, streamHead: streamItems });
expect(
getHistoryLiveBoundaryIndexForStreamRenderStrategy({
strategy: forward,
history: forwardHistory,
}),
).toBe(1);
expect(
getHistoryLiveBoundaryIndexForStreamRenderStrategy({
strategy: inverted,
history: invertedHistory,
}),
).toBe(0);
});
expect(getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy({ strategy, liveHead })).toBe(
0,
);
},
);
it("uses the oldest live-head edge as the live-head/history boundary", () => {
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const inverted = resolveStreamRenderStrategy({
platform: "ios",
isMobileBreakpoint: false,
});
it.each(PLATFORMS)("renders a cell's content before its footer on %s", (platform) => {
expect(getFrameChildOrderForStreamRenderStrategy({ strategy: strategyFor(platform) })).toBe(
const forwardHead = orderHeadForStreamRenderStrategy({
strategy: forward,
streamHead: streamItems,
});
const invertedHead = orderHeadForStreamRenderStrategy({
strategy: inverted,
streamHead: streamItems,
});
expect(
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy({
strategy: forward,
liveHead: forwardHead,
}),
).toBe(0);
expect(
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy({
strategy: inverted,
liveHead: invertedHead,
}),
).toBe(1);
});
it("names the frame child order needed by native inverted cells", () => {
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const inverted = resolveStreamRenderStrategy({
platform: "android",
isMobileBreakpoint: false,
});
expect(getFrameChildOrderForStreamRenderStrategy({ strategy: forward })).toBe(
"content-then-footer",
);
expect(getFrameChildOrderForStreamRenderStrategy({ strategy: inverted })).toBe(
"footer-then-content",
);
});
});
@@ -41,23 +41,23 @@ describe("scroll keyboard dismissal", () => {
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1040, y: -8 },
{ ts: 1080, y: -16 },
{ ts: 1120, y: -24 },
{ ts: 1040, y: 8 },
{ ts: 1080, y: 16 },
{ ts: 1120, y: 24 },
],
{ ts: 1160, y: -32 },
{ ts: 1160, y: 32 },
),
).toBe(false);
});
it("dismisses for a flick toward older messages", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: -120 }], { ts: 1080, y: -240 })).toBe(
it("dismisses for an upward flick", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: 120 }], { ts: 1080, y: 240 })).toBe(
true,
);
});
it("keeps the keyboard up when the list moves toward newer messages", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: 120 }], { ts: 1080, y: 240 })).toBe(
it("keeps the keyboard up when the inverted list moves toward newer messages", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: -120 }], { ts: 1080, y: -240 })).toBe(
false,
);
});
@@ -67,17 +67,17 @@ describe("scroll keyboard dismissal", () => {
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1100, y: -500 },
{ ts: 1200, y: -590 },
{ ts: 1270, y: -598 },
{ ts: 1100, y: 500 },
{ ts: 1200, y: 590 },
{ ts: 1270, y: 598 },
],
{ ts: 1300, y: -600 },
{ ts: 1300, y: 600 },
),
).toBe(false);
});
it("uses the whole drag when the gesture is too short to sample", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1020, y: -60 })).toBe(true);
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1020, y: 60 })).toBe(true);
});
it("steps back a sample when release lands immediately after one", () => {
@@ -85,22 +85,22 @@ describe("scroll keyboard dismissal", () => {
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1040, y: -120 },
{ ts: 1075, y: -225 },
{ ts: 1040, y: 120 },
{ ts: 1075, y: 225 },
],
{ ts: 1080, y: -240 },
{ ts: 1080, y: 240 },
),
).toBe(true);
});
it("keeps the keyboard up when release has no measurable time span", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1000, y: -90 })).toBe(false);
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1000, y: 90 })).toBe(false);
});
it("ignores scroll events that arrive before the minimum sample span", () => {
expect(
shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1029, y: -1000 }], { ts: 1060, y: -1000 }),
).toBe(true);
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1029, y: 1000 }], { ts: 1060, y: 1000 })).toBe(
true,
);
});
it("uses native gesture time when delayed callbacks arrive in a burst", () => {
@@ -108,32 +108,32 @@ describe("scroll keyboard dismissal", () => {
shouldDismiss(
{ ts: 5000, nativeTs: 1000, y: 0 },
[
{ ts: 5001, nativeTs: 1200, y: -40 },
{ ts: 5002, nativeTs: 1400, y: -80 },
{ ts: 5001, nativeTs: 1200, y: 40 },
{ ts: 5002, nativeTs: 1400, y: 80 },
],
{ ts: 5003, nativeTs: 1600, y: -120 },
{ ts: 5003, nativeTs: 1600, y: 120 },
),
).toBe(false);
});
it("falls back to synthetic event time when native time is absent", () => {
expect(
shouldDismiss({ ts: 1000, nativeTs: null, y: 0 }, [{ ts: 1040, nativeTs: null, y: -120 }], {
shouldDismiss({ ts: 1000, nativeTs: null, y: 0 }, [{ ts: 1040, nativeTs: null, y: 120 }], {
ts: 1080,
nativeTs: null,
y: -240,
y: 240,
}),
).toBe(true);
});
it("ignores scroll and release events without a matching drag", () => {
const idle = recordScroll(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, event({ ts: 1000, y: -200 }));
const idle = recordScroll(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, event({ ts: 1000, y: 200 }));
expect(idle).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
expect(releaseDrag(idle, event({ ts: 1010, y: -300 })).shouldDismiss).toBe(false);
expect(releaseDrag(idle, event({ ts: 1010, y: 300 })).shouldDismiss).toBe(false);
});
it("returns to idle after release", () => {
const release = releaseDrag(beginDrag(event({ ts: 1000, y: 0 })), event({ ts: 1020, y: -60 }));
const release = releaseDrag(beginDrag(event({ ts: 1000, y: 0 })), event({ ts: 1020, y: 60 }));
expect(release.gesture).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
});
});
@@ -2,7 +2,8 @@
* Pure state machine for the chat history's flick-to-dismiss gesture.
*
* Native keyboardDismissMode cannot express this interaction: "on-drag"
* dismisses on the first pixel, while "interactive" is iOS-only. We therefore classify the list's existing scroll gesture at
* dismisses on the first pixel, while "interactive" behaves incorrectly on an
* inverted list. We therefore classify the list's existing scroll gesture at
* release instead of introducing a competing pan recognizer.
*/
@@ -113,12 +114,11 @@ export function releaseDrag(
spanStartY = samples.previousSampleY;
}
// The list runs oldest to newest, so moving toward older messages lowers the offset.
const spanDurationMs = releaseTs - spanStartTs;
const towardOlderVelocity = spanDurationMs > 0 ? (spanStartY - releaseY) / spanDurationMs : 0;
const releaseVelocity = spanDurationMs > 0 ? (releaseY - spanStartY) / spanDurationMs : 0;
return {
gesture: IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
shouldDismiss: towardOlderVelocity > DISMISS_VELOCITY_POINTS_PER_MS,
shouldDismiss: releaseVelocity > DISMISS_VELOCITY_POINTS_PER_MS,
};
}
@@ -18,7 +18,7 @@ type ScrollEvent = NativeSyntheticEvent<NativeScrollEvent>;
/**
* Owns the chat history's flick-to-dismiss behavior. The native stream only
* forwards the LegendList scroll lifecycle; removing this hook and those three
* forwards the FlatList scroll lifecycle; removing this hook and those three
* calls removes the feature completely.
*/
export function useScrollKeyboardDismiss() {
+185 -291
View File
@@ -1,42 +1,38 @@
import {
Fragment,
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import {
FlatList,
Platform,
View,
type LayoutChangeEvent,
type ListRenderItemInfo,
type NativeScrollEvent,
type NativeSyntheticEvent,
type ScrollView,
type ScrollViewProps,
type ViewStyle,
} from "react-native";
import {
LegendList,
type LegendListRef,
type LegendListRenderItemProps,
useSyncLayout,
} from "@legendapp/list/react-native";
import { withUnistyles } from "react-native-unistyles";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useKeyboardStreamInset } from "@/keyboard/shift";
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 { EndPinnedScrollView } from "./native-end-pin";
import { createNewRowEstimates } from "./new-row-estimates";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy, isNearBottomForStreamRenderStrategy } from "./strategy";
import {
createStreamStrategy,
isNearBottomForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
} from "./strategy";
import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
@@ -53,6 +49,11 @@ import {
type HistoryStartSettleScheduler,
} from "./history-start-settle-scheduler";
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
minIndexForVisible: 0,
autoscrollToTopThreshold: 0,
});
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
@@ -64,50 +65,7 @@ const historyStartSlotStyle: ViewStyle = {
flexShrink: 0,
};
const HISTORY_START_SETTLE_FRAMES = 2;
// Rendered distance on each side of the viewport. Smaller values blank the viewport while a fling
// crosses rows that were never measured (docs/agent-stream-disclosure.md).
const NATIVE_STREAM_DRAW_DISTANCE = 2000;
// Following resumes after a disclosure or a user scroll only from the end, so resuming never moves
// the rows the user is looking at. Web resumes within the same distance (strategy-web.tsx).
const AT_END_OFFSET_PX = 1;
// A disclosure settles after this many frames without an item size change.
const DISCLOSURE_QUIET_FRAMES = 2;
const BOTTOM_ANCHOR_TRANSPORT_BEHAVIOR = Object.freeze({
verificationDelayFrames: 2,
verificationRetryMode: "recheck" as const,
});
type RowLayoutSyncRegistry = Map<string, () => void>;
// A disclosure toggle changes a row from the row's own state, so LegendList's container does not
// re-render and would learn the new height from onLayout a task later, a frame after the change
// painted. The row's measurement trigger, called next to the toggle, batches the measurement and
// the corrected positions into the toggle's own commit.
function RowLayoutSync(props: {
rowId: string;
registry: RowLayoutSyncRegistry;
children: ReactNode;
}) {
const { rowId, registry } = props;
const syncLayout = useSyncLayout();
useLayoutEffect(() => {
registry.set(rowId, syncLayout);
return () => {
if (registry.get(rowId) === syncLayout) {
registry.delete(rowId);
}
};
}, [registry, rowId, syncLayout]);
return props.children;
}
function distanceFromEnd(input: {
offsetY: number;
contentHeight: number;
viewportHeight: number;
}): number {
return Math.max(0, input.contentHeight - input.viewportHeight - input.offsetY);
}
const NATIVE_STREAM_WINDOW_SIZE = 10;
function keyExtractor(item: { id: string }): string {
return item.id;
@@ -136,12 +94,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
strategy,
} = props;
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
const listRef = useRef<LegendListRef>(null);
// LegendList places the rows of each agent once, behind a hidden reveal (`dataKey`).
const [placedAgentId, setPlacedAgentId] = useState<string | null>(null);
const isListPlaced = placedAgentId === agentId;
const isListPlacedRef = useRef(isListPlaced);
isListPlacedRef.current = isListPlaced;
const flatListRef = useRef<FlatList<StreamItem>>(null);
const streamViewportMetricsRef = useRef({
containerKey: "native-virtualized",
contentHeight: 0,
@@ -154,9 +107,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const scrollOffsetYRef = useRef(0);
const isUserScrollActiveRef = useRef(false);
const scrollKeyboardDismiss = useScrollKeyboardDismiss();
const streamKeyboardInset = useKeyboardStreamInset();
const userScrollEndFrameIdRef = useRef<number | null>(null);
const isReleasedAtEndRef = useRef(false);
const programmaticScrollEventBudgetRef = useRef(0);
const isNativeViewportSettlingRef = useRef(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
createHistoryStartPaginationState,
@@ -171,28 +126,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
return [...segments.historyVirtualized, ...segments.historyMounted];
}, [segments.historyMounted, segments.historyVirtualized]);
const historyRows = useRevisedHistoryRows(historyItems, historyRowRevision);
// Live rows are the tail of the list, so a row that finishes keeps its id, container and measured
// size when it moves into history. A fresh identity per live-head change re-renders every live
// row, which is how they pick up neighbour and expansion changes (`liveHeadRowRevision`).
const liveRows = useMemo(() => {
void liveHeadRowRevision;
return segments.liveHead.map((item) => ({ ...item }));
}, [segments.liveHead, liveHeadRowRevision]);
const liveRowIds = useMemo(() => new Set(liveRows.map((item) => item.id)), [liveRows]);
const [newRowEstimates] = useState(() => createNewRowEstimates<StreamItem>());
const rows = useMemo(() => {
const nextRows = [...historyRows, ...liveRows];
newRowEstimates.noteRows(nextRows);
return nextRows;
}, [newRowEstimates, historyRows, liveRows]);
const getEstimatedItemSize = useStableEvent((item: StreamItem) => newRowEstimates.estimate(item));
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => {
const metrics = streamViewportMetricsRef.current;
const hasMeasuredViewport =
metrics.viewportMeasuredForKey === metrics.containerKey &&
metrics.contentMeasuredForKey === metrics.containerKey;
return {
distanceFromHistoryStart: metrics.offsetY,
distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY,
hasOlderHistory,
isLoadingOlderHistory,
isReady: historyStartReadyRef.current && hasMeasuredViewport,
@@ -256,6 +196,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scheduler.schedule();
});
const clearNativeViewportSettling = useCallback(() => {
if (nativeViewportSettlingFrameIdRef.current !== null) {
cancelAnimationFrame(nativeViewportSettlingFrameIdRef.current);
nativeViewportSettlingFrameIdRef.current = null;
}
}, []);
const clearPendingUserScrollEnd = useCallback(() => {
if (userScrollEndFrameIdRef.current !== null) {
cancelAnimationFrame(userScrollEndFrameIdRef.current);
@@ -263,30 +210,49 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
}, []);
const getBottomAnchorTransportBehavior = useCallback(() => BOTTOM_ANCHOR_TRANSPORT_BEHAVIOR, []);
const markNativeViewportSettling = useCallback(() => {
clearNativeViewportSettling();
isNativeViewportSettlingRef.current = true;
let remainingFrames = 4;
const tick = () => {
if (remainingFrames <= 0) {
nativeViewportSettlingFrameIdRef.current = null;
isNativeViewportSettlingRef.current = false;
return;
}
remainingFrames -= 1;
nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick);
};
nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick);
}, [clearNativeViewportSettling]);
const getBottomAnchorTransportBehavior = useCallback(
() =>
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: isNativeViewportSettlingRef.current,
}),
[strategy],
);
const scrollToBottom = useCallback(
(animated: boolean) => {
programmaticScrollEventBudgetRef.current = 3;
const metrics = streamViewportMetricsRef.current;
// The native scroll view finds its end from the content it has mounted, and the end pin holds
// it from there. LegendList's own scrollToEnd keeps re-aiming at the end its JS sizes predict
// until it lands, which runs ahead of the mount and scrolls the end out of view.
const list = listRef.current;
list?.cancelScroll();
// The scroll component is `EndPinnedScrollView`, a ScrollView.
(list?.getNativeScrollRef() as ScrollView | undefined)?.scrollToEnd({
flatListRef.current?.scrollToOffset({
offset: 0,
animated:
animated &&
shouldAnimateNativeScrollToBottom({
distanceFromEnd: distanceFromEnd(metrics),
viewportHeight: metrics.viewportHeight,
drawDistance: NATIVE_STREAM_DRAW_DISTANCE,
offsetY: scrollOffsetYRef.current,
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
windowSize: NATIVE_STREAM_WINDOW_SIZE,
}),
});
const endOffsetY = Math.max(0, metrics.contentHeight - metrics.viewportHeight);
scrollOffsetYRef.current = endOffsetY;
streamViewportMetricsRef.current = { ...metrics, offsetY: endOffsetY };
scrollOffsetYRef.current = 0;
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
offsetY: 0,
};
onNearBottomChange(true);
},
[onNearBottomChange],
@@ -296,7 +262,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
agentId,
routeRequest: routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
renderStrategy: "legend-stream",
renderStrategy: "inverted-stream",
getTransportBehavior: getBottomAnchorTransportBehavior,
getMeasurementState: () => streamViewportMetricsRef.current,
isNearBottom: () => {
@@ -311,76 +277,39 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
},
scrollToBottom,
});
const [rowLayoutSyncRegistry] = useState<RowLayoutSyncRegistry>(() => new Map());
const isAtEnd = useStableEvent(
() => distanceFromEnd(streamViewportMetricsRef.current) <= AT_END_OFFSET_PX,
);
const releaseDisclosure = useStableEvent(() => {
bottomAnchorController.releaseDisclosure({ isAtEnd: isAtEnd() });
});
const [disclosureHold] = useState(() =>
createNativeDisclosureHold({
quietFrames: DISCLOSURE_QUIET_FRAMES,
requestFrame: requestAnimationFrame,
cancelFrame: cancelAnimationFrame,
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 handleItemSizeChanged = useStableEvent(
(info: { size: number; previous: number; itemKey: string; itemData: StreamItem }) => {
newRowEstimates.noteMeasured(info.itemData, info.size);
disclosureHold.noteItemSizeChanged();
},
);
const shouldRestorePosition = useStableEvent((item: StreamItem) => {
const heldRowId = disclosureHold.heldRowId();
return heldRowId === null || item.id === heldRowId;
});
// 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
// keeps the rows hidden until they are measured and in place, so neither the pin nor the
// controller acts; the content does not count as measured yet (`handleContentSizeChange`).
// - Following the end: the end pin. Rows added, rows resized and footer changes never scroll from
// JS; the pin holds the end in the frame the change mounts. The controller scrolls only for a
// viewport resize (the composer growing or shrinking), which the pin cannot see, and for an
// explicit jump, and both go to the native end (`scrollToBottom`). The keyboard does not resize
// the viewport: it translates the list with the composer. LegendList's end keeping stays off: it
// 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.
// - 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;
const maintainVisibleContentPosition = useMemo(
() => (isFollowingEnd ? false : { data: true, size: true, shouldRestorePosition }),
[isFollowingEnd, shouldRestorePosition],
);
// A new identity re-renders LegendList's scroll component, which is how the pin follows the mode.
const renderScrollComponent = useCallback(
(scrollProps: ScrollViewProps) => <EndPinnedScrollView {...scrollProps} pinEnd={pinsEnd} />,
[pinsEnd],
);
// Android's maintainVisibleContentPosition ignores the list inversion transform and
// fights the controller's offset-zero correction while the live header grows.
const maintainVisibleContentPosition =
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
? undefined
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
const topOverlayInset = useStreamTopOverlayInset();
// No keyboard inset: one committed after the keyboard settles moves every row when the end pin is
// not holding the offset (docs/agent-stream-disclosure.md).
// The list is inverted, so its content's bottom padding is the visual top.
const listContentContainerStyle = useMemo(
() => [baseListContentContainerStyle, { paddingTop: topOverlayInset }],
[baseListContentContainerStyle, topOverlayInset],
() => [
baseListContentContainerStyle,
{
paddingBottom: streamKeyboardInset.contentContainerPaddingBottom + topOverlayInset,
},
],
[
baseListContentContainerStyle,
streamKeyboardInset.contentContainerPaddingBottom,
topOverlayInset,
],
);
const listInsetProps = useMemo(
() =>
streamKeyboardInset.contentInset
? {
automaticallyAdjustContentInsets: false,
automaticallyAdjustsScrollIndicatorInsets: false,
contentInsetAdjustmentBehavior: "never" as const,
contentInset: streamKeyboardInset.contentInset,
scrollIndicatorInsets: streamKeyboardInset.contentInset,
}
: {},
[streamKeyboardInset.contentInset],
);
useEffect(() => {
@@ -396,7 +325,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scrollOffsetYRef.current = 0;
isUserScrollActiveRef.current = false;
clearPendingUserScrollEnd();
disclosureHold.cancel();
clearNativeViewportSettling();
isNativeViewportSettlingRef.current = false;
historyStartReadyRef.current = false;
const initialHistoryStartState = createHistoryStartPaginationState();
historyStartPaginationStateRef.current = initialHistoryStartState;
@@ -411,7 +341,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [agentId, clearPendingUserScrollEnd, disclosureHold, evaluateHistoryStart]);
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]);
useEffect(() => () => clearNativeViewportSettling(), [clearNativeViewportSettling]);
useEffect(() => {
bottomAnchorController.prepareForStickyContentChange();
}, [bottomAnchorController, historyRows, segments.liveHead]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -423,8 +359,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
},
prepareForViewportChange: () => {
bottomAnchorController.prepareForStickyViewportChange();
markNativeViewportSettling();
},
holdRow,
holdReadingPosition: () => bottomAnchorController.detachByUser(),
};
viewportRef.current = handle;
return () => {
@@ -432,32 +369,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
viewportRef.current = null;
}
};
}, [agentId, bottomAnchorController, holdRow, viewportRef]);
// The scroll-to-bottom button shows whenever following would not resume on its own. A detached
// list resumes only from the end, so the button shows anywhere short of it. While following, the
// offset can trail content growth for a frame, so the controller's wider threshold keeps the
// button from flashing.
const publishNearBottom = useStableEvent(() => {
const metrics = streamViewportMetricsRef.current;
onNearBottomChange(
bottomAnchorController.mode === "detached"
? distanceFromEnd(metrics) <= AT_END_OFFSET_PX
: isNearBottomForStreamRenderStrategy({
strategy,
offsetY: metrics.offsetY,
threshold: 32,
contentHeight: metrics.contentHeight,
viewportHeight: metrics.viewportHeight,
}),
);
});
// Following starts and stops without a scroll, for example when a disclosure hold begins short of
// the end, and the button follows it.
useEffect(() => {
publishNearBottom();
}, [isFollowingEnd, publishNearBottom]);
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
const isScrollEventNearBottom = useStableEvent(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
@@ -472,17 +384,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
},
);
const isScrollEventAtEnd = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
return (
distanceFromEnd({
offsetY: contentOffset.y,
contentHeight: contentSize.height,
viewportHeight: layoutMeasurement.height,
}) <= AT_END_OFFSET_PX
);
});
const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const previousOffsetY = scrollOffsetYRef.current;
@@ -500,18 +401,14 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
};
const nearBottom = isScrollEventNearBottom(event);
publishNearBottom();
onNearBottomChange(nearBottom);
evaluateHistoryStart();
if (
!isUserScrollActiveRef.current &&
programmaticScrollEventBudgetRef.current > 0 &&
distanceFromEnd({
offsetY: contentOffset.y,
contentHeight: contentSize.height,
viewportHeight: layoutMeasurement.height,
}) <= 8
contentOffset.y <= 8
) {
programmaticScrollEventBudgetRef.current -= 1;
} else {
@@ -527,10 +424,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
scrollKeyboardDismiss.onScrollBeginDrag(event);
// 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();
bottomAnchorController.beginUserScroll();
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
if (rearmed !== historyStartPaginationStateRef.current) {
@@ -540,22 +433,17 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
});
// A gesture ends at the end when the finger lifts at or past it, or when its momentum stops there.
// A release past the end bounces back to it on iOS, and lines that land during the bounce scroll
// in as they do while following. Momentum stopping short of the end, or output that arrived below
// the viewport before the decision, leaves the list detached.
// Drag end is deferred a frame so momentum can take ownership.
// Defer drag end so momentum can take ownership, but capture the terminal
// gesture position now because layout may move the viewport in the meantime.
const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const isNearBottom = isScrollEventNearBottom(event);
scrollKeyboardDismiss.onScrollEndDrag(event);
isReleasedAtEndRef.current = isScrollEventAtEnd(event);
clearPendingUserScrollEnd();
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
userScrollEndFrameIdRef.current = null;
isUserScrollActiveRef.current = false;
bottomAnchorController.endUserScroll({
isAtEnd: isReleasedAtEndRef.current || isAtEnd(),
});
bottomAnchorController.endUserScroll({ isNearBottom });
});
});
@@ -570,11 +458,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
if (!isUserScrollActiveRef.current) {
return;
}
const isNearBottom = isScrollEventNearBottom(event);
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = false;
bottomAnchorController.endUserScroll({
isAtEnd: isReleasedAtEndRef.current || isScrollEventAtEnd(event),
});
bottomAnchorController.endUserScroll({ isNearBottom });
},
);
@@ -583,6 +470,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
const viewportWidth = Math.max(0, event.nativeEvent.layout.width);
const viewportHeight = Math.max(0, event.nativeEvent.layout.height);
const viewportChanged =
(previousViewportWidth > 0 && previousViewportWidth !== viewportWidth) ||
(previousViewportHeight > 0 && previousViewportHeight !== viewportHeight);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
containerKey: "native-virtualized",
@@ -590,6 +480,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
viewportHeight,
viewportMeasuredForKey: "native-virtualized",
};
if (viewportChanged) {
markNativeViewportSettling();
}
bottomAnchorController.handleViewportMetricsChange({
previousViewportWidth,
viewportWidth,
@@ -600,37 +493,24 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
const handleContentSizeChange = useStableEvent((_width: number, height: number) => {
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
const nextContentHeight = Math.max(0, height);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
containerKey: "native-virtualized",
contentHeight: nextContentHeight,
contentMeasuredForKey: isListPlacedRef.current ? "native-virtualized" : null,
contentMeasuredForKey: "native-virtualized",
};
bottomAnchorController.handleContentSizeChange();
// Content growing below a detached viewport moves the end away without a scroll event.
if (
bottomAnchorController.mode === "detached" &&
streamViewportMetricsRef.current.viewportHeight > 0
) {
publishNearBottom();
}
bottomAnchorController.handleContentSizeChange({
previousContentHeight,
contentHeight: nextContentHeight,
});
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartSettle();
}
});
const handleListPlaced = useStableEvent(() => {
isListPlacedRef.current = true;
setPlacedAgentId(agentId);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
contentMeasuredForKey: "native-virtualized",
};
bottomAnchorController.handleContentSizeChange();
});
useEffect(() => {
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
@@ -644,35 +524,45 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scheduleHistoryStartSettle,
]);
// LegendList re-renders a mounted row only when its item or `extraData` changes, not when
// renderItem does. Item identity carries content and display changes (`useRevisedHistoryRows`);
// `extraData` carries the history renderer, whose identity changes with the history layout (a
// reply gaining its completed footer when the next prompt arrives) and keeps its row's item.
// StreamRow's memo limits that pass to the rows whose layout item changed.
const renderItem = useStableEvent(
({ item, index }: LegendListRenderItemProps<StreamItem>): ReactElement => (
<RowLayoutSync rowId={item.id} registry={rowLayoutSyncRegistry}>
{liveRowIds.has(item.id)
? renderLiveHeadRow(item, index - historyRows.length, segments.liveHead)
: renderHistoryMountedRow(item, index, historyItems)}
</RowLayoutSync>
),
({ item, index }: ListRenderItemInfo<StreamItem>): ReactElement | null => {
const rendered = renderHistoryMountedRow(item, index, historyItems);
return (rendered ?? null) as ReactElement | null;
},
);
const liveAuxiliaryContent = useMemo(() => {
const liveHeaderContent = useMemo(() => {
// Stable render events read the latest expansion state; this revision makes
// the memo invoke them again when that state changes.
void liveHeadRowRevision;
const liveHeadRows = segments.liveHead.map((item, index) => (
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
));
const liveAuxiliary = renderLiveAuxiliary();
if (
liveRows.length === 0 &&
liveHeadRows.length === 0 &&
!liveAuxiliary &&
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory
) {
return (listEmptyComponent ?? null) as ReactElement | null;
}
return <View>{liveAuxiliary}</View>;
}, [boundary, listEmptyComponent, liveRows.length, renderLiveAuxiliary]);
return (
<Fragment>
{liveHeadRows}
{liveAuxiliary}
</Fragment>
);
}, [
boundary,
listEmptyComponent,
liveHeadRowRevision,
renderLiveAuxiliary,
renderLiveHeadRow,
segments.liveHead,
]);
const historyStartContent = useMemo(() => {
const historyFooterContent = useMemo(() => {
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
return (
<View style={historyStartSlotStyle} testID="older-history-slot">
@@ -685,21 +575,21 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
);
}, [historyStartPaginationState]);
// RN's FlatList strictMode keeps its internal renderItem wrapper stable when
// data or the live header changes, preserving the row identities above.
return (
<LegendList<StreamItem>
ref={listRef}
data={rows}
<FlatList
{...listInsetProps}
ref={flatListRef}
data={historyRows}
renderItem={renderItem}
extraData={renderHistoryMountedRow}
keyExtractor={keyExtractor}
strictMode
testID="agent-chat-scroll"
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={historyStartContent}
ListFooterComponent={liveAuxiliaryContent ?? undefined}
ListHeaderComponent={liveHeaderContent ?? undefined}
ListFooterComponent={historyFooterContent ?? undefined}
contentContainerStyle={listContentContainerStyle}
automaticallyAdjustContentInsets={false}
automaticallyAdjustsScrollIndicatorInsets={false}
contentInsetAdjustmentBehavior="never"
style={listStyle}
onLayout={handleListLayout}
onScroll={handleScroll}
@@ -709,18 +599,16 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onMomentumScrollEnd={handleMomentumScrollEnd}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
onItemSizeChanged={handleItemSizeChanged}
onReady={handleListPlaced}
dataKey={agentId}
maintainVisibleContentPosition={maintainVisibleContentPosition}
renderScrollComponent={renderScrollComponent}
recycleItems={false}
alignItemsAtEnd
initialScrollAtEnd
drawDistance={NATIVE_STREAM_DRAW_DISTANCE}
getEstimatedItemSize={getEstimatedItemSize}
// Tall Markdown rows exhaust a shallow window during flings. Keep the initial
// render small, then refill a deeper window in batches large enough to keep up.
initialNumToRender={2}
maxToRenderPerBatch={10}
windowSize={NATIVE_STREAM_WINDOW_SIZE}
removeClippedSubviews={false}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
inverted
/>
);
}
@@ -728,20 +616,26 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
export function createNativeStreamStrategy(): StreamStrategy {
const strategy = createStreamStrategy({
render: (renderInput) => <NativeStreamViewport {...renderInput} strategy={strategy} />,
orderTailReverse: false,
orderHeadReverse: false,
assistantTurnTraversalStep: -1,
edgeSlot: "footer",
historyLiveBoundaryEdge: "last",
liveHeadHistoryBoundaryEdge: "first",
frameChildOrder: "content-then-footer",
bottomAnchorTransportBehavior: BOTTOM_ANCHOR_TRANSPORT_BEHAVIOR,
orderTailReverse: true,
orderHeadReverse: true,
assistantTurnTraversalStep: 1,
edgeSlot: "header",
historyLiveBoundaryEdge: "first",
liveHeadHistoryBoundaryEdge: "last",
frameChildOrder: "footer-then-content",
flatListInverted: true,
overlayScrollbarInverted: true,
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
bottomAnchorTransportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
disableParentScrollOnInlineDetailsExpansion: false,
anchorBottomOnContentSizeChange: false,
animateManualScrollToBottom: true,
useVirtualizedList: true,
isNearBottom: (input) => distanceFromEnd(input) <= input.threshold,
getBottomOffset: (metrics) => Math.max(0, metrics.contentHeight - metrics.viewportHeight),
isNearBottom: (input) => input.offsetY <= input.threshold,
getBottomOffset: () => 0,
});
return strategy;
}
@@ -1,158 +0,0 @@
import React, { act } from "react";
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 { createWebStreamStrategy } from "./strategy-web";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const VIEWPORT_HEIGHT = 100;
const ROW_HEIGHT = 40;
// A row's height comes from its text, so a disclosure that opens or grows a row is a new item
// for that row, as it is in the app.
function row(index: number, height = ROW_HEIGHT): Extract<StreamItem, { kind: "user_message" }> {
return {
kind: "user_message",
id: `message-${index}`,
text: String(height),
timestamp: new Date(`2026-04-20T00:00:${String(index % 60).padStart(2, "0")}.000Z`),
};
}
const rowStyleByHeight = new Map<number, { height: number }>();
function renderRow(item: StreamItem) {
const height = item.kind === "user_message" ? Number(item.text) : ROW_HEIGHT;
let style = rowStyleByHeight.get(height);
if (!style) {
style = { height };
rowStyleByHeight.set(height, style);
}
return <div style={style}>{item.id}</div>;
}
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
host = document.createElement("div");
host.style.cssText = `display:flex;flex-direction:column;width:500px;height:${VIEWPORT_HEIGHT}px`;
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(() => {
act(() => root.unmount());
host.remove();
});
function mountTimeline(historyMounted: StreamItem[]) {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const input: StreamRenderInput = {
agentId: "agent",
segments: { historyVirtualized: [], historyMounted, liveHead: [] },
boundary: { hasVirtualizedHistory: false, hasMountedHistory: true, hasLiveHead: false },
renderers: {
renderHistoryVirtualizedRow: renderRow,
renderHistoryMountedRow: renderRow,
renderLiveHeadRow: renderRow,
renderLiveAuxiliary: () => null,
},
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: () => {},
onNearHistoryStart: () => true,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
const render = (segments: Partial<StreamRenderInput["segments"]>) =>
root.render(strategy.render({ ...input, segments: { ...input.segments, ...segments } }));
act(() => render({}));
const scrollContainer = host.querySelector<HTMLElement>('[data-testid="agent-chat-scroll"]');
if (!scrollContainer) throw new Error("Expected agent chat scroll container");
const rowElement = (id: string) => {
const element = host.querySelector<HTMLElement>(`[data-history-row-id="${id}"]`);
if (!element) throw new Error(`Expected row ${id}`);
return element;
};
return {
render: (segments: Partial<StreamRenderInput["segments"]>) => act(() => render(segments)),
hold: (rowId: string, apply: () => void) =>
act(() => viewportRef.current!.holdRow(rowId, apply)),
rowTop: (id: string) =>
rowElement(id).getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
distanceFromEnd: () =>
scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop,
// Resize observers and the hold's settle run on animation frames.
settle: () =>
act(async () => {
for (let frame = 0; frame < 4; frame += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
}),
};
}
describe("web disclosure hold", () => {
it("keeps the trigger's top when an anchored toggle at the bottom opens rows below it, and stops following", async () => {
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
const timeline = mountTimeline(history);
await timeline.settle();
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
const triggerTop = timeline.rowTop("message-5");
timeline.hold("message-5", () =>
timeline.render({ historyMounted: [...history.slice(0, 5), row(7, 200), history[5]!] }),
);
await timeline.settle();
expect(timeline.rowTop("message-5")).toBe(triggerTop);
timeline.render({ liveHead: [row(8)] });
await timeline.settle();
expect(timeline.rowTop("message-5")).toBe(triggerTop);
});
it("keeps the trigger's top when its row grows while following output", async () => {
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
const timeline = mountTimeline(history);
await timeline.settle();
const triggerTop = timeline.rowTop("message-5");
timeline.hold("message-5", () =>
timeline.render({ historyMounted: [...history.slice(0, 4), row(5, 240), history[5]!] }),
);
await timeline.settle();
expect(timeline.rowTop("message-5")).toBe(triggerTop);
});
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);
await timeline.settle();
timeline.hold("message-5", () =>
timeline.render({ historyMounted: [...history.slice(0, 4), row(5, 240), history[5]!] }),
);
await timeline.settle();
timeline.hold("message-5", () => timeline.render({ historyMounted: history }));
await timeline.settle();
timeline.render({ liveHead: [row(8)] });
await timeline.settle();
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
});
});
+16 -29
View File
@@ -363,8 +363,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const wasFollowOutputLayoutActiveRef = useRef(false);
const resumedUnchangedLayoutRef = useRef(false);
const pendingResumeGeometryCheckRef = useRef(false);
// Set by holdRow; the next reading reconcile sees the disclosure's layout.
const disclosurePendingRef = useRef(false);
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
const {
renderHistoryVirtualizedRow,
@@ -730,27 +728,22 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
onNearBottomChange,
});
// Row tops in content coordinates, which survive scrolling.
const measureRows = useStableEvent(() => {
const reconcileReadingPosition = useStableEvent(() => {
const container = scrollContainerRef.current;
const content = contentRef.current;
if (!container || !content) return null;
if (!isActiveRef.current || !container || !content) return;
const viewportTop = container.getBoundingClientRect().top;
const scrollTop = container.scrollTop;
const width = content.getBoundingClientRect().width;
return Array.from(content.querySelectorAll<HTMLElement>("[data-history-row-id]"), (element) => {
const bounds = element.getBoundingClientRect();
const id = element.dataset.historyRowId!;
measuredRowHeights.current.set(id, { width, height: bounds.height });
return { id, top: bounds.top - viewportTop + scrollTop, height: bounds.height };
});
});
const reconcileReadingPosition = useStableEvent(() => {
const container = scrollContainerRef.current;
if (!isActiveRef.current || !container) return;
const rows = measureRows();
if (!rows) return;
const rows = Array.from(
content.querySelectorAll<HTMLElement>("[data-history-row-id]"),
(element) => {
const bounds = element.getBoundingClientRect();
const id = element.dataset.historyRowId!;
measuredRowHeights.current.set(id, { width, height: bounds.height });
return { id, top: bounds.top - viewportTop + scrollTop, height: bounds.height };
},
);
const shouldAnchor = !followOutputRef.current && !isJumpSettling();
if (!shouldAnchor) readingAnchor.reset();
const correctedTop = readingAnchor.reconcile(
@@ -770,11 +763,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
// this layout so the reader does not get a frame containing only the pin.
virtualOffsetListener.current?.(container.scrollTop, false);
}
// A disclosure that closes back to the end of the list rejoins the output.
if (disclosurePendingRef.current) {
disclosurePendingRef.current = false;
if (isScrollContainerAtBottom(container)) setFollowOutput(true);
}
});
// Apply row positions and their scroll correction in the same paint, including
@@ -1194,13 +1182,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
scheduleStickToBottom();
},
holdRow: (rowId, apply) => {
holdReadingPosition: () => {
cancelPendingStickToBottom();
setFollowOutput(false);
const row = measureRows()?.find((candidate) => candidate.id === rowId);
if (row) readingAnchor.pin(row);
disclosurePendingRef.current = true;
apply();
readingAnchor.scroll(scrollContainerRef.current?.scrollTop ?? 0);
},
scrollToMessage,
};
@@ -1214,7 +1199,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [
cancelPendingStickToBottom,
forceStickToBottom,
measureRows,
readingAnchor,
scheduleStickToBottom,
scrollToMessage,
@@ -1408,6 +1392,9 @@ export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): St
historyLiveBoundaryEdge: "last",
liveHeadHistoryBoundaryEdge: "first",
frameChildOrder: "content-then-footer",
flatListInverted: false,
overlayScrollbarInverted: false,
maintainVisibleContentPosition: undefined,
bottomAnchorTransportBehavior: {
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
+35 -5
View File
@@ -14,6 +14,11 @@ type NeighborRelation = "above" | "below";
type AssistantTurnTraversalStep = -1 | 1;
export type StreamFrameChildOrder = "content-then-footer" | "footer-then-content";
export type MaintainVisibleContentPositionConfig = Readonly<{
minIndexForVisible: number;
autoscrollToTopThreshold: number;
}>;
export type BottomAnchorTransportBehavior = Readonly<{
verificationDelayFrames: number;
verificationRetryMode: "rescroll" | "recheck";
@@ -51,11 +56,8 @@ export interface StreamViewportHandle {
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.
*/
holdRow: (rowId: string, apply: () => void) => void;
/** Stop following output so the rows on screen stay put while rows below them open or close. */
holdReadingPosition: () => void;
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
}
@@ -123,11 +125,14 @@ export interface StreamStrategy {
component: ReactElement | ComponentType<unknown> | null,
gapSize: number,
) => StreamEdgeSlotProps;
getMaintainVisibleContentPosition: () => MaintainVisibleContentPositionConfig | undefined;
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
getHistoryLiveBoundaryIndex: (history: StreamItem[]) => number | null;
getLiveHeadHistoryBoundaryIndex: (liveHead: StreamItem[]) => number | null;
getLatestItemIndex: (items: StreamItem[]) => number | null;
getFrameChildOrder: () => StreamFrameChildOrder;
getFlatListInverted: () => boolean;
getOverlayScrollbarInverted: () => boolean;
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
shouldAnchorBottomOnContentSizeChange: () => boolean;
shouldAnimateManualScrollToBottom: () => boolean;
@@ -143,6 +148,9 @@ interface StreamStrategyConfig {
historyLiveBoundaryEdge: "first" | "last";
liveHeadHistoryBoundaryEdge: "first" | "last";
frameChildOrder: StreamFrameChildOrder;
flatListInverted: boolean;
overlayScrollbarInverted: boolean;
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
bottomAnchorTransportBehavior: BottomAnchorTransportBehavior;
disableParentScrollOnInlineDetailsExpansion: boolean;
anchorBottomOnContentSizeChange: boolean;
@@ -152,6 +160,8 @@ interface StreamStrategyConfig {
getBottomOffset: (metrics: StreamViewportMetrics) => number;
}
const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4;
export function createStreamStrategy(config: StreamStrategyConfig): StreamStrategy {
return {
render: config.render,
@@ -206,6 +216,7 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate
ListFooterComponentStyle: { marginTop: gapSize },
};
},
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
getBottomAnchorTransportBehavior: () => config.bottomAnchorTransportBehavior,
getHistoryLiveBoundaryIndex: (history) => {
if (history.length === 0) {
@@ -226,6 +237,8 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate
return config.historyLiveBoundaryEdge === "first" ? 0 : items.length - 1;
},
getFrameChildOrder: () => config.frameChildOrder,
getFlatListInverted: () => config.flatListInverted,
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
config.disableParentScrollOnInlineDetailsExpansion,
shouldAnchorBottomOnContentSizeChange: () => config.anchorBottomOnContentSizeChange,
@@ -234,6 +247,23 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate
};
}
export function resolveBottomAnchorTransportBehavior(input: {
strategy: StreamStrategy;
isViewportSettling: boolean;
}): BottomAnchorTransportBehavior {
const baseBehavior = input.strategy.getBottomAnchorTransportBehavior();
if (!input.isViewportSettling || !input.strategy.getFlatListInverted()) {
return baseBehavior;
}
return {
verificationDelayFrames: Math.max(
baseBehavior.verificationDelayFrames,
NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES,
),
verificationRetryMode: "recheck",
};
}
export function orderTailForStreamRenderStrategy(params: {
strategy: StreamStrategy;
streamItems: StreamItem[];
@@ -1,69 +0,0 @@
import { act, createRef } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import type { StreamLayoutItem } from "./layout";
import type { StreamViewportHandle } from "./strategy";
import { renderStreamRow } from "./stream-row";
// stream-row.tsx is compiled with the React Compiler here, as Expo compiles it.
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(() => {
act(() => root.unmount());
host.remove();
});
const turnHost: StreamItem = {
kind: "user_message",
id: "host",
text: "Fix the build",
timestamp: new Date(0),
};
const turnHostLayout: StreamLayoutItem = {
item: turnHost,
aboveItem: null,
// The turn's rows land below another row, so opening it leaves the host's layout unchanged.
belowItem: null,
gapBelow: 0,
assistantSpacing: "default",
completedFooter: null,
toolSequence: "none",
isFirstInUserGroup: true,
isLastInUserGroup: true,
isLastInToolSequence: false,
frameOrder: "content-then-footer",
phase: "complete",
};
it("renders a revised row again when its layout is unchanged", () => {
// The turn's open state lives outside the row, as `getCollapsedTurn` reads it in view.tsx.
let turnOpen = false;
const renderStreamItem = () => <span>{turnOpen ? "open" : "closed"}</span>;
const viewportRef = createRef<StreamViewportHandle>();
const layoutItemById = new Map([[turnHost.id, turnHostLayout]]);
const renderRow = (item: StreamItem) =>
act(() =>
root.render(renderStreamRow({ item, layoutItemById, renderStreamItem, viewportRef })),
);
renderRow(turnHost);
expect(host.textContent).toBe("closed");
turnOpen = true;
// useRevisedHistoryRows hands the row a fresh item when its display state changes.
renderRow({ ...turnHost });
expect(host.textContent).toBe("open");
});
@@ -1,58 +0,0 @@
import { memo, type ReactNode, type RefObject } from "react";
import type { StreamItem } from "@/types/stream";
import { DisclosureRow } from "./disclosure";
import type { StreamLayoutItem } from "./layout";
import type { StreamViewportHandle } from "./strategy";
/**
* Renders a timeline row from the item the row was handed. The item is a revision of
* `layoutItem.item` with the same content: render from it, so a revision that leaves the layout
* unchanged still renders the row again.
*/
export type RenderStreamItem = (item: StreamItem, layoutItem: StreamLayoutItem) => ReactNode;
// Live and retained rows use the same boundary so completion preserves the message
// subtree, including measured diagrams and selections. LegendList renders every mounted row
// again when `extraData` (the history renderer) changes, and history rows shift index when older
// history loads. This boundary limits that pass: a row renders again only when its stream item
// identity, its layout item identity, or the renderer itself changes. Item identity is the
// revision signal the strategy already uses (`useRevisedHistoryRows` clones items whose content or
// display state changed). The row passes the item to the renderer, so the React Compiler's cache
// of the rendered content keys on it too.
const StreamRow = memo(function StreamRow({
item,
layoutItem,
renderStreamItem,
viewportRef,
}: {
item: StreamItem;
layoutItem: StreamLayoutItem;
renderStreamItem: RenderStreamItem;
viewportRef: RefObject<StreamViewportHandle | null>;
}) {
return (
<DisclosureRow rowId={item.id} viewportRef={viewportRef}>
{renderStreamItem(item, layoutItem)}
</DisclosureRow>
);
});
export function renderStreamRow(input: {
item: StreamItem;
layoutItemById: Map<string, StreamLayoutItem>;
renderStreamItem: RenderStreamItem;
viewportRef: RefObject<StreamViewportHandle | null>;
}): ReactNode {
const layoutItem = input.layoutItemById.get(input.item.id);
if (!layoutItem) {
return null;
}
return (
<StreamRow
item={input.item}
layoutItem={layoutItem}
renderStreamItem={input.renderStreamItem}
viewportRef={input.viewportRef}
/>
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { createContext, useContext } from "react";
import { isNative } from "@/constants/platform";
/** The native viewport reserves top clearance under the overlay; web stays in flow. */
/** The native inverted viewport reserves visual-top clearance; web stays in flow. */
export const supportsStreamTopOverlay = isNative;
const StreamTopOverlayContext = createContext(0);
@@ -41,7 +41,6 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
isLatestTurnOpen: false,
expandedHostIds: new Set(),
getGroupedCalls: () => undefined,
previousTurnsByHostId: new Map(),
...options,
});
}
@@ -49,37 +48,6 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
const ids = (items: StreamItem[]) => items.map((item) => item.id);
describe("collapseTurns", () => {
// A history row renders again only when its item changes, so a prompt that is already retained
// when its turn closes needs its row revised to show the summary line.
it("reports the hosts whose summary line appeared, changed or went away", () => {
const open = collapse([user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2")], {
isLatestTurnOpen: true,
});
expect([...open.updatedHostIds]).toEqual(["u1"]);
const closed = collapse(
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")],
{
previousTurnsByHostId: open.turnsByHostId,
},
);
expect([...closed.updatedHostIds]).toEqual(["u2"]);
const unchanged = collapse(
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")],
{
previousTurnsByHostId: closed.turnsByHostId,
},
);
expect(unchanged.updatedHostIds.size).toBe(0);
const moreWork = collapse(
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), shell("c3"), reply("r2")],
{ previousTurnsByHostId: unchanged.turnsByHostId },
);
expect([...moreWork.updatedHostIds]).toEqual(["u2"]);
const rewound = collapse([user("u1"), shell("c1"), reply("r1")], {
previousTurnsByHostId: moreWork.turnsByHostId,
});
expect([...rewound.updatedHostIds]).toEqual(["u2"]);
});
it("maps hidden rows only while their turn is closed", () => {
const result = collapse([user("u1"), shell("c1"), reply("final")]);
expect(result.hostIdByHiddenRowId.get("c1")).toBe("u1");
@@ -27,11 +27,6 @@ export interface TurnCollapse {
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
/** Mounted rows replaced by a closed turn's host, for preserving the reading position. */
hostIdByHiddenRowId: ReadonlyMap<string, string>;
/**
* Hosts whose summary line appeared, changed or went away since `previousTurnsByHostId`. A
* history row renders again only when its item changes, so these rows are revised.
*/
updatedHostIds: ReadonlySet<string>;
}
export interface CollapseTurnsInput {
@@ -41,8 +36,6 @@ export interface CollapseTurnsInput {
expandedHostIds: ReadonlySet<string>;
/** The calls a tool-call group row stands for; undefined for a single call. */
getGroupedCalls: (itemId: string) => readonly (ToolCallItem | ThoughtItem)[] | undefined;
/** The previous fold of the same history, the baseline for `updatedHostIds`. */
previousTurnsByHostId: ReadonlyMap<string, CollapsedTurn>;
}
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
@@ -66,36 +59,6 @@ function hasWork(summary: TurnSummary): boolean {
return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0);
}
function isSameTurn(a: CollapsedTurn, b: CollapsedTurn): boolean {
const { paseoActions: actionsA, ...countsA } = a.summary.tools;
const { paseoActions: actionsB, ...countsB } = b.summary.tools;
return (
a.expanded === b.expanded &&
hasSameCounts(countsA, countsB) &&
hasSameCounts(actionsA, actionsB)
);
}
function hasSameCounts(a: Record<string, number>, b: Record<string, number>): boolean {
const keys = Object.keys(a);
return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]);
}
function findUpdatedHosts(
previous: ReadonlyMap<string, CollapsedTurn>,
next: ReadonlyMap<string, CollapsedTurn>,
): ReadonlySet<string> {
const updated = new Set<string>();
for (const [hostId, turn] of next) {
const before = previous.get(hostId);
if (!before || !isSameTurn(before, turn)) updated.add(hostId);
}
for (const hostId of previous.keys()) {
if (!next.has(hostId)) updated.add(hostId);
}
return updated.size === 0 ? EMPTY_IDS : updated;
}
function staysVisibleWhenCollapsed(item: StreamItem): boolean {
return isTurnTrigger(item) || item.kind === "compaction";
}
@@ -174,7 +137,6 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
}
flushRun(tail.length);
const updatedHostIds = findUpdatedHosts(input.previousTurnsByHostId, turnsByHostId);
if (turnsByHostId.size === 0) {
return {
tail,
@@ -182,7 +144,6 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
nestedItemIds: EMPTY_IDS,
hostIdByFoldedMessageId: EMPTY_HOSTS,
hostIdByHiddenRowId: EMPTY_HOSTS,
updatedHostIds,
};
}
return {
@@ -194,6 +155,5 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
nestedItemIds,
hostIdByFoldedMessageId,
hostIdByHiddenRowId,
updatedHostIds,
};
}
@@ -1,7 +1,6 @@
import { memo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Summary } from "lucide-react-native";
import { useDisclosureHold } from "@/agent-stream/disclosure";
import { ExpandableBadge } from "@/components/message";
import { useOverviewSummaryLabel } from "@/tool-calls/detail-level/overview/label";
import type { CollapsedTurn } from "./model";
@@ -19,10 +18,9 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({
}) {
const { t } = useTranslation();
const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details");
const hold = useDisclosureHold();
const toggle = useCallback(
() => hold(() => onExpandedChange(turn.hostId, !turn.expanded)),
[hold, onExpandedChange, turn.expanded, turn.hostId],
() => onExpandedChange(turn.hostId, !turn.expanded),
[onExpandedChange, turn.expanded, turn.hostId],
);
return (
+83 -65
View File
@@ -74,8 +74,6 @@ import { TurnSummaryLine } from "./turn-collapse/summary-line";
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
import { renderStreamRow, type RenderStreamItem } from "./stream-row";
import { type NearBottom, useIsNearBottom, useNearBottom } from "./near-bottom";
import { ChatOutlineRail } from "@/agent-stream/chat-outline/rail";
import { useChatOutline } from "@/agent-stream/chat-outline/use-chat-outline";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -232,6 +230,41 @@ function renderListEmptyComponent(input: {
);
}
// Live and retained rows use the same boundary so completion preserves the message
// subtree, including measured diagrams and selections. FlatList recreates history
// cells with fresh refs and shifted indexes on data changes. This boundary is
// what stops that churn: a row renders again only when its stream item identity, its layout item
// identity, or the renderer itself changes. Item identity is the revision signal the strategy
// already uses (`useRevisedHistoryRows` clones items whose content or display state changed).
const StreamRow = memo(function StreamRow({
layoutItem,
renderStreamItem,
}: {
item: StreamItem;
layoutItem: StreamLayoutItem;
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
}) {
return <>{renderStreamItem(layoutItem)}</>;
});
function renderStreamRow(input: {
item: StreamItem;
layoutItemById: Map<string, StreamLayoutItem>;
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
}): ReactNode {
const layoutItem = input.layoutItemById.get(input.item.id);
if (!layoutItem) {
return null;
}
return (
<StreamRow
item={input.item}
layoutItem={layoutItem}
renderStreamItem={input.renderStreamItem}
/>
);
}
export interface AgentStreamViewHandle {
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
prepareForViewportChange(): void;
@@ -291,47 +324,6 @@ function resolveBottomOverlayControlOffset(clearance: number | undefined): numbe
return Math.max(16, clearance ?? 0);
}
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android";
const scrollIndicatorFadeIn = shouldDisableEntryExitAnimations ? undefined : FadeIn.duration(200);
const scrollIndicatorFadeOut = shouldDisableEntryExitAnimations ? undefined : FadeOut.duration(200);
function ScrollToBottomButton({
nearBottom,
isTimelineDetached,
bottomOffset,
onPress,
}: {
nearBottom: NearBottom;
isTimelineDetached: boolean;
bottomOffset: number;
onPress: () => void;
}) {
const { t } = useTranslation();
const isNearBottom = useIsNearBottom(nearBottom);
const containerStyle = useMemo(
() => [stylesheet.scrollToBottomContainer, { bottom: bottomOffset }],
[bottomOffset],
);
if (isNearBottom && !isTimelineDetached) return null;
return (
<View style={containerStyle} pointerEvents="box-none">
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
<Pressable
style={stylesheet.scrollToBottomButton}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={t("agentStream.scrollToBottom")}
testID="scroll-to-bottom-button"
>
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
</Pressable>
</Animated.View>
</View>
);
}
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
function AgentStreamView(
{
@@ -373,7 +365,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}),
[isMobile],
);
const nearBottom = useNearBottom(agentId);
const [isNearBottom, setIsNearBottom] = useState(true);
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
new Set(),
);
@@ -430,7 +422,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
loadOlder: historyPagination.onLoadOlder,
}
: agentHistoryPagination;
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android";
const scrollIndicatorFadeIn = shouldDisableEntryExitAnimations
? undefined
: FadeIn.duration(200);
const scrollIndicatorFadeOut = shouldDisableEntryExitAnimations
? undefined
: FadeOut.duration(200);
useEffect(() => {
setIsNearBottom(true);
setExpandedInlineToolCallIds(new Set());
setExpandedTurnHostIds(new Set());
}, [agentId]);
@@ -672,8 +675,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[streamRenderStrategy],
);
// The summary line holds its row around this; chat find opens a turn without a hold.
const setTurnExpanded = useCallback((hostId: string, expanded: boolean) => {
// A turn opens below its summary line; keep that line under the reader's pointer.
viewportRef.current?.holdReadingPosition();
setExpandedTurnHostIds((previous) => {
if (previous.has(hostId) === expanded) return previous;
const next = new Set(previous);
@@ -924,7 +928,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const renderStreamItemContent = useCallback(
(item: StreamItem, layoutItem: StreamLayoutItem) => {
(layoutItem: StreamLayoutItem) => {
const item = layoutItem.item;
switch (item.kind) {
case "user_message":
return renderUserMessageItem(layoutItem, item);
@@ -985,9 +990,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter;
const renderStreamItem = useCallback<RenderStreamItem>(
(item, layoutItem) => {
const content = renderStreamItemContent(item, layoutItem);
const renderStreamItem = useCallback(
(layoutItem: StreamLayoutItem) => {
const content = renderStreamItemContent(layoutItem);
return renderStreamItemWithTurnFooter({
content,
layoutItem,
@@ -1056,7 +1061,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]);
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
const scrollToBottomOffset = resolveBottomOverlayControlOffset(bottomOverlayControlClearance);
const scrollToBottomContainerStyle = useMemo(
() => [
stylesheet.scrollToBottomContainer,
{ bottom: resolveBottomOverlayControlOffset(bottomOverlayControlClearance) },
],
[bottomOverlayControlClearance],
);
const listEmptyComponent = useMemo(
() =>
renderListEmptyComponent({
@@ -1099,7 +1110,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
item,
layoutItemById: layoutHistoryItemById,
renderStreamItem,
viewportRef,
}),
[layoutHistoryItemById, renderStreamItem],
);
@@ -1121,7 +1131,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
item,
layoutItemById: layoutLiveHeadItemById,
renderStreamItem,
viewportRef,
}),
);
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
@@ -1156,13 +1165,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const streamScrollEnabled =
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
expandedInlineToolCallIds.size === 0;
const { nestedItemIds, updatedHostIds: updatedTurnHostIds } = presentation.turns;
const { nestedItemIds } = presentation.turns;
const historyRowRevision = useMemo(
() => ({
contentById: {
has: (id: string) =>
presentation.historyGroupUpdatesByHostId.has(id) || updatedTurnHostIds.has(id),
},
contentById: presentation.historyGroupUpdatesByHostId,
displayStateById: {
// An opened lone call also marks its future group open; that row stays unrevised
// until a group actually forms there.
@@ -1180,7 +1186,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isMobile,
nestedItemIds,
presentation.historyGroupUpdatesByHostId,
updatedTurnHostIds,
],
);
@@ -1214,7 +1219,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange: nearBottom.set,
onNearBottomChange: setIsNearBottom,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
@@ -1233,12 +1238,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
activePrompt={chatOutline.activePrompt}
onJumpToPrompt={chatOutline.jumpToPrompt}
/>
<ScrollToBottomButton
nearBottom={nearBottom}
isTimelineDetached={isTimelineDetached}
bottomOffset={scrollToBottomOffset}
onPress={scrollToBottom}
/>
{(!isNearBottom || isTimelineDetached) && (
<View style={scrollToBottomContainerStyle} pointerEvents="box-none">
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
<Pressable
style={stylesheet.scrollToBottomButton}
onPress={scrollToBottom}
accessibilityRole="button"
accessibilityLabel={t("agentStream.scrollToBottom")}
testID="scroll-to-bottom-button"
>
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
</Pressable>
</Animated.View>
</View>
)}
</AssistantSelectionCopySurface>
</ToolCallSheetProvider>
</ChatFind>
@@ -1775,6 +1789,10 @@ const stylesheet = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
invertedWrapper: {
transform: [{ scaleY: -1 }],
width: "100%",
},
emptyStateText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
+20 -48
View File
@@ -1,12 +1,4 @@
import { useHardwareKeyboardPresent } from "@/keyboard";
import {
WindowLayoutProvider,
WindowLayoutActionsHost,
WindowLayoutSurface,
WindowLayoutDivisionGap,
useWindowLayout,
resolveDesktopDivisionLayout,
} from "@/window-layout";
import { AddHostProvider, AddHostFlowHost } from "@/hosts/add-host";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
@@ -67,7 +59,6 @@ import {
resolveDesktopAppChromeLayout,
resolveDesktopAppContentMinimum,
resolveDesktopSidebarVisibility,
resolveSidebarActivity,
} from "@/components/desktop-sidebar-layout";
import { isNative, isWeb } from "@/constants/platform";
import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
@@ -364,16 +355,9 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const { width: windowWidth } = useWindowDimensions();
const insets = useSafeAreaInsets();
const viewportWidth = windowWidth - insets.left - insets.right;
const divisionLayout = resolveDesktopDivisionLayout({
geometry: useWindowLayout(),
windowWidth,
windowHeight,
leftInset: insets.left,
rightInset: insets.right,
});
const cycleTheme = useCallback(() => {
void updateSettings({ theme: getNextThemePreference(settings.theme) });
@@ -434,7 +418,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
isCompactLayout,
isMounted: desktopSidebarMounted,
isOpen: isDesktopAgentListOpen,
divisionLayout,
canShare: canDesktopAppSidebarShare({
contentMinimumWidth: appContentMinimumWidth,
requestedSidebarWidth: sidebarWidth,
@@ -458,19 +441,18 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
if (isWeb) {
themedSidebarChrome = <AppearanceStyleBoundary>{sidebarChrome}</AppearanceStyleBoundary>;
}
const workspaceChromeStyle = useMemo(
() => [rowStyle, !isCompactLayout && { paddingLeft: insets.left, paddingRight: insets.right }],
[insets.left, insets.right, isCompactLayout],
);
const workspaceChrome = (
<WindowLayoutSurface compact={isCompactLayout} style={rowStyle}>
<View style={workspaceChromeStyle}>
<WorkspaceLayoutModePublisher />
{!isCompactLayout ? (
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
{themedSidebarChrome}
</WindowChromeRegion>
) : null}
<WindowLayoutDivisionGap
layout={divisionLayout}
sidebarVisible={desktopSidebarVisible}
compact={isCompactLayout}
/>
<WorkspaceExplorerOpenGesture enabled={chromeEnabled && isCompactLayout}>
<WindowChromeRegion
corners={isCompactLayout && chromeEnabled ? "both" : appChromeLayout.contentCorners}
@@ -478,7 +460,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<View style={flexStyle}>{children}</View>
</WindowChromeRegion>
</WorkspaceExplorerOpenGesture>
</WindowLayoutSurface>
</View>
);
// Native panel gesture hosts outlive appearance keys, like native navigators.
@@ -566,7 +548,8 @@ function SidebarChrome({
}) {
const isCompactLayout = useIsCompactFormFactor();
const isMobileActive = useIsMobilePanelActive("agent-list");
const active = resolveSidebarActivity(visible, isCompactLayout, isMobileActive);
const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen);
const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen);
const hardwareKeyboardPresent = useHardwareKeyboardPresent();
return (
<SidebarModelProvider
@@ -892,31 +875,20 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
// auth, settings, ...) must wrap PortalProvider, not be wrapped by it.
// BottomSheetModalProvider is the exception: Gorhom modals consume portal
// context and need one shared provider for sibling sheets to stack.
function RootOverlayProviders({ children }: { children: ReactNode }) {
return (
<ToastProvider>
<PortalProvider>
<BottomSheetModalProvider>
{children}
<WindowLayoutActionsHost />
</BottomSheetModalProvider>
</PortalProvider>
</ToastProvider>
);
}
function RootProviders({ children }: { children: ReactNode }) {
return (
<KeyboardActionDispatcherProvider>
<WindowLayoutProvider>
<WindowChromeProvider>
<KeyboardProvider>
<KeyboardShiftProvider>
<RootOverlayProviders>{children}</RootOverlayProviders>
</KeyboardShiftProvider>
</KeyboardProvider>
</WindowChromeProvider>
</WindowLayoutProvider>
<WindowChromeProvider>
<KeyboardProvider>
<KeyboardShiftProvider>
<ToastProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</ToastProvider>
</KeyboardShiftProvider>
</KeyboardProvider>
</WindowChromeProvider>
</KeyboardActionDispatcherProvider>
);
}
@@ -13,7 +13,6 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
@@ -293,7 +292,7 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: 0,
},
trigger: {
height: TOOLBAR_CONTROL_HEIGHT,
height: 28,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
@@ -9,7 +9,6 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useHostReportsUsage } from "@/usage";
import type { Theme } from "@/styles/theme";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { ContextWindowDetails } from "./context-window-details";
import { ContextWindowSheet } from "./context-window-sheet";
import { formatContextPercentage } from "./context-window-meter.utils";
@@ -307,14 +306,14 @@ export function ContextWindowMeter({
const styles = StyleSheet.create((theme) => ({
container: {
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
containerWithLabel: {
height: TOOLBAR_CONTROL_HEIGHT,
height: 28,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
@@ -1,97 +1,13 @@
import { describe, expect, it } from "vitest";
import {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
resolveWorkspaceBarActions,
type WindowLayout,
} from "@/window-layout";
import {
canDesktopAppSidebarShare,
resolveDesktopAppChromeLayout,
resolveDesktopAppContentMinimum,
resolveDesktopSidebarVisibility,
resolveSidebarActivity,
resolveDesktopSidebarWidth,
} from "@/components/desktop-sidebar-layout";
describe("desktop sidebar layout", () => {
const geometry: WindowLayout = {
bounds: { x: 0, y: 0, width: 951, height: 669 },
verticalBarEdge: "trailing",
verticalBar: { x: 875, y: 160, width: 44, height: 450 },
divisions: [{ x: 465, y: 0, width: 21, height: 669 }],
};
it("places workspace action symbols in the reported trailing bar", () => {
expect(resolveWorkspaceActionPlacement(geometry)).toBe("vertical-bar");
expect(resolveWorkspaceActionPlacement(null)).toBe("header");
expect(resolveWorkspaceActionPlacement({ ...geometry, verticalBarEdge: "none" })).toBe(
"header",
);
expect(resolveWorkspaceActionPlacement({ ...geometry, verticalBar: null })).toBe("header");
expect(resolveWorkspaceActionPlacement({ ...geometry, verticalBarEdge: "leading" })).toBe(
"header",
);
});
it("shows the tab count only for the compact header selection", () => {
expect(resolveWorkspaceBarActions("compact")).toEqual(["tab-count", "overflow", "explorer"]);
expect(resolveWorkspaceBarActions("full")).toEqual(["overflow", "explorer"]);
});
it("allocates both panels around a live division without clamping to saved sidebar widths", () => {
const input = {
geometry,
windowWidth: 951,
windowHeight: 669,
leftInset: 0,
rightInset: 84,
};
expect(resolveDesktopDivisionLayout(input)).toEqual({
sidebarWidth: 465,
gap: 21,
contentWidth: 381,
});
expect(
resolveDesktopDivisionLayout({ ...input, geometry: { ...geometry, divisions: [] } }),
).toBeNull();
expect(
resolveDesktopDivisionLayout({ ...input, windowWidth: 669, windowHeight: 951 }),
).toBeNull();
expect(resolveDesktopDivisionLayout({ ...input, geometry: null })).toBeNull();
expect(
resolveDesktopDivisionLayout({
...input,
geometry: { ...geometry, divisions: [{ x: 0, y: 325, width: 951, height: 19 }] },
}),
).toBeNull();
});
it("activates a revealed folded sidebar while preserving closed desktop intent", () => {
expect(resolveSidebarActivity(true, false, false)).toBe(true);
expect(resolveSidebarActivity(false, false, false)).toBe(false);
expect(resolveSidebarActivity(true, true, false)).toBe(false);
expect(resolveSidebarActivity(true, true, true)).toBe(true);
});
it("temporarily reveals navigation for a division without changing the saved closed intent", () => {
const visibility = {
chromeEnabled: true,
isCompactLayout: false,
isMounted: true,
isOpen: false,
canShare: false,
};
expect(
resolveDesktopSidebarVisibility({
...visibility,
divisionLayout: { sidebarWidth: 465, gap: 21, contentWidth: 381 },
}),
).toBe(true);
expect(resolveDesktopSidebarVisibility({ ...visibility, divisionLayout: null })).toBe(false);
expect(visibility.isOpen).toBe(false);
});
it("keeps a retained sidebar hidden while app chrome is suppressed", () => {
expect(
resolveDesktopSidebarVisibility({
@@ -1,7 +1,6 @@
import { EXPLORER_WORKSPACE_MIN_WIDTH } from "@/components/explorer-sidebar-layout";
import { SETTINGS_DESKTOP_SPLIT_MIN_WIDTH } from "@/constants/layout";
import { MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
import type { DesktopDivisionLayout } from "@/window-layout";
const MIN_DESKTOP_CENTER_WIDTH = 400;
@@ -11,13 +10,13 @@ export function resolveDesktopSidebarVisibility(input: {
isMounted: boolean;
isOpen: boolean;
canShare: boolean;
divisionLayout?: DesktopDivisionLayout | null;
}): boolean {
return (
input.chromeEnabled &&
!input.isCompactLayout &&
input.isMounted &&
(input.divisionLayout != null || (input.isOpen && input.canShare))
input.isOpen &&
input.canShare
);
}
@@ -86,11 +85,3 @@ export function canDesktopAppSidebarShare(input: {
input.contentMinimumWidth
);
}
export function resolveSidebarActivity(
visible: boolean,
compact: boolean,
mobileActive: boolean,
): boolean {
return visible && (!compact || mobileActive);
}
+23 -38
View File
@@ -26,7 +26,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarCloseButton } from "@/components/headers/sidebar-close-button";
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
@@ -529,7 +528,6 @@ function MobileSidebar({
insetsBottom,
closeSidebar,
}: MobileSidebarProps) {
const insets = useSafeAreaInsets();
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
@@ -544,10 +542,10 @@ function MobileSidebar({
onPress={closeSidebar}
testID="sidebar-close"
nativeID="sidebar-close"
style={styles.mobileCloseButton(insets.right)}
style={styles.mobileCloseButton}
/>
),
[labels.closeSidebar, closeSidebar, insets.right],
[labels.closeSidebar, closeSidebar],
);
const mobileSidebarInsetStyle = useMemo(
@@ -668,22 +666,13 @@ function DesktopSidebar({
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const { width: windowWidth } = useWindowDimensions();
const insets = useSafeAreaInsets();
const viewportWidth = windowWidth - insets.left - insets.right;
const divisionLayout = resolveDesktopDivisionLayout({
geometry: useWindowLayout(),
windowWidth,
windowHeight,
leftInset: insets.left,
rightInset: insets.right,
const visibleSidebarWidth = resolveDesktopSidebarWidth({
requestedWidth: sidebarWidth,
viewportWidth,
});
const visibleSidebarWidth =
divisionLayout?.sidebarWidth ??
resolveDesktopSidebarWidth({
requestedWidth: sidebarWidth,
viewportWidth,
});
const startWidthRef = useRef(visibleSidebarWidth);
const resizeWidth = useSharedValue(visibleSidebarWidth);
@@ -698,7 +687,6 @@ function DesktopSidebar({
const resizeGesture = useMemo(
() =>
Gesture.Pan()
.enabled(divisionLayout === null)
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
.onBegin(() => {
scheduleOnRN(showResizeGrip);
@@ -720,14 +708,13 @@ function DesktopSidebar({
viewportWidth,
});
})
.onEnd((_event, success) => {
if (success) runOnJS(setSidebarWidth)(resizeWidth.value);
.onEnd(() => {
runOnJS(setSidebarWidth)(resizeWidth.value);
})
.onFinalize(() => {
scheduleOnRN(hideResizeGrip);
}),
[
divisionLayout,
hideResizeGrip,
resizeWidth,
setSidebarWidth,
@@ -738,7 +725,7 @@ function DesktopSidebar({
);
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: divisionLayout?.sidebarWidth ?? resizeWidth.value,
width: resizeWidth.value,
}));
const desktopSidebarStyle = useMemo(
@@ -751,10 +738,10 @@ function DesktopSidebar({
);
const desktopSidebarBorderStyle = useMemo(
() => [
styles.desktopSidebarBorder(divisionLayout !== null),
styles.desktopSidebarBorder,
{ flex: 1, paddingTop: insetsTop, paddingBottom: insets.bottom },
],
[insetsTop, insets.bottom, divisionLayout],
[insetsTop, insets.bottom],
);
const sidebarHeaderGroupStyle = useMemo(
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
@@ -825,14 +812,12 @@ function DesktopSidebar({
handleOpenHostSettings={handleOpenHostSettings}
/>
{divisionLayout === null ? (
<SidebarResizeHandle
edge="right"
gesture={resizeGesture}
pressed={resizePressed}
testID="left-sidebar-resize-handle"
/>
) : null}
<SidebarResizeHandle
edge="right"
gesture={resizeGesture}
pressed={resizePressed}
testID="left-sidebar-resize-handle"
/>
</View>
</Animated.View>
);
@@ -898,14 +883,14 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minHeight: 0,
},
mobileCloseButton: (rightInset: number) => ({
marginRight: theme.spacing[2] + rightInset,
}),
desktopSidebarBorder: (hasDivision: boolean) => ({
borderRightWidth: hasDivision ? 0 : 1,
mobileCloseButton: {
marginRight: theme.spacing[2],
},
desktopSidebarBorder: {
borderRightWidth: 1,
borderRightColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
}),
},
sidebarDragArea: {
position: "relative",
},
+4 -7
View File
@@ -1,4 +1,3 @@
import { useDisclosureHold } from "@/agent-stream/disclosure";
import { MessagePreview, MessageSender } from "@/components/user-message";
import { useStableEvent } from "@/hooks/use-stable-event";
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
@@ -1484,9 +1483,9 @@ export const TodoListCard = memo(function TodoListCard({
};
}, [activity, t]);
const hold = useDisclosureHold();
const toggleExpanded = useCallback(() => setIsExpanded((prev) => !prev), []);
const handleToggle = useCallback(() => hold(toggleExpanded), [hold, toggleExpanded]);
const handleToggle = useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);
const renderDetails = useCallback(() => {
return (
@@ -2280,7 +2279,6 @@ export const ToolCall = memo(function ToolCall({
const isMobile = useIsCompactFormFactor();
const shouldRenderInline = !isMobile || forceInline;
const hold = useDisclosureHold();
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
if (detail) {
@@ -2332,10 +2330,9 @@ export const ToolCall = memo(function ToolCall({
showLoadingSkeleton: presentation.isLoadingDetails,
});
} else {
hold(toggleExpanded);
toggleExpanded();
}
}, [
hold,
shouldRenderInline,
toggleExpanded,
openToolCall,
@@ -1,7 +1,7 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { CONTROL_HEIGHTS, mapLayout, type ControlSize } from "@/components/ui/control-geometry";
import { CONTROL_HEIGHTS, type ControlSize } from "@/components/ui/control-geometry";
import { settingsStyles } from "@/styles/settings";
@@ -49,7 +49,7 @@ const styles = StyleSheet.create((theme) => ({
// trailing button line up.
nestedRow: {
paddingVertical: theme.spacing[2],
minHeight: mapLayout(CONTROL_HEIGHTS.sm, (height) => height + theme.spacing[2] * 2),
minHeight: CONTROL_HEIGHTS.sm + theme.spacing[2] * 2,
},
nestedText: {
fontSize: theme.fontSize.sm,
@@ -154,7 +154,6 @@ import type { HostBadgeModel } from "@/hosts/appearance";
import { useHostBadges } from "@/hosts/use-host-badges";
import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model";
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
import { bySizing } from "@/components/ui/control-geometry";
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
@@ -2738,12 +2737,12 @@ const styles = StyleSheet.create((theme) => ({
marginRight: -6,
},
projectKebabButton: {
// Larger on touch so the kebab is an easy tap target. The negative margins keep its
// dots on the same rail as the 24px pointer box and the row at its 44px height.
width: bySizing({ touch: 32, pointer: 24 }),
height: bySizing({ touch: 32, pointer: 24 }),
marginRight: bySizing({ touch: -4, pointer: 0 }),
marginVertical: bySizing({ touch: -4, pointer: 0 }),
// Larger on compact so the kebab is an easy tap target. The negative margins keep its
// dots on the same rail as the 24px desktop box and the row at its 44px height.
width: { xs: 32, md: 24 },
height: { xs: 32, md: 24 },
marginRight: { xs: -4, md: 0 },
marginVertical: { xs: -4, md: 0 },
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
@@ -36,7 +36,6 @@ import {
import { Shortcut } from "@/components/ui/shortcut";
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
import { resolveSidebarWorkspaceAccessibilityLabel } from "@/components/sidebar/sidebar-workspace-title";
import { bySizing } from "@/components/ui/control-geometry";
import {
workspaceServiceLabelKey,
type WorkspaceServiceSummary,
@@ -439,18 +438,18 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere
const styles = StyleSheet.create((theme) => ({
trigger: {
// Larger on touch so the kebab is an easy tap target.
width: bySizing({ touch: 32, pointer: 18 }),
height: bySizing({ touch: 32, pointer: 18 }),
// Larger on compact so the kebab is an easy tap target.
width: { xs: 32, md: 18 },
height: { xs: 32, md: 18 },
alignItems: "center",
justifyContent: "center",
borderRadius: 4,
marginLeft: 2,
// Center the touch box on the 20px title line it is anchored to.
marginTop: bySizing({ touch: -6, pointer: 0 }),
// Center the compact box on the 20px title line it is anchored to.
marginTop: { xs: -6, md: 0 },
// MoreVertical paints only around the center of its SVG. Keep the padded hit box, but
// pull the painted dots through that unused view-box space onto the trailing-content rail.
marginRight: bySizing({ touch: -14, pointer: -7 }),
marginRight: { xs: -14, md: -7 },
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
+9 -9
View File
@@ -64,8 +64,6 @@ import {
import { FloatingSurface } from "@/components/ui/floating";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
import { density, densityIconBox } from "@/styles/density";
import { DensityIcon } from "@/components/ui/density-icon";
import { buildDesktopFrameStyle } from "./combobox-frame-style";
export { buildDesktopFrameStyle } from "./combobox-frame-style";
@@ -205,7 +203,7 @@ export function SearchInput({
return (
<View style={styles.searchInputContainer}>
<DensityIcon icon={Search} size="md" color="foregroundMuted" />
<Search size={16} color={theme.colors.foregroundMuted} />
<AdaptiveTextInput
ref={inputRef}
// @ts-expect-error - outlineStyle is web-only
@@ -252,19 +250,21 @@ export function ComboboxItem({
onPress,
testID,
}: ComboboxItemProps): ReactElement {
const { theme } = useUnistyles();
let leadingContent: ReactElement | null = null;
if (leadingSlot) {
leadingContent = <View style={styles.comboboxItemLeadingSlot}>{leadingSlot}</View>;
} else if (kind === "directory") {
leadingContent = (
<View style={styles.comboboxItemLeadingSlot}>
<DensityIcon icon={Folder} size="md" color="foregroundMuted" />
<Folder size={16} color={theme.colors.foregroundMuted} />
</View>
);
} else if (kind === "file") {
leadingContent = (
<View style={styles.comboboxItemLeadingSlot}>
<DensityIcon icon={File} size="md" color="foregroundMuted" />
<File size={16} color={theme.colors.foregroundMuted} />
</View>
);
}
@@ -308,7 +308,7 @@ export function ComboboxItem({
{selected || trailingSlot ? (
<View style={styles.comboboxItemTrailingContainer}>
<View style={styles.comboboxItemTrailingSlot}>
{selected ? <DensityIcon icon={Check} size="md" color="foregroundMuted" /> : null}
{selected ? <Check size={16} color={theme.colors.foregroundMuted} /> : null}
</View>
{trailingSlot}
</View>
@@ -1682,7 +1682,7 @@ const styles = StyleSheet.create((theme) => ({
comboboxItem: {
flexDirection: "row",
alignItems: "center",
minHeight: density.rowHeight,
minHeight: 36,
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
@@ -1713,7 +1713,7 @@ const styles = StyleSheet.create((theme) => ({
opacity: 0.55,
},
comboboxItemTrailingSlot: {
width: densityIconBox("md"),
width: 16,
alignItems: "center",
justifyContent: "center",
},
@@ -1733,7 +1733,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
comboboxItemLeadingSlot: {
width: densityIconBox("md"),
width: 16,
alignItems: "center",
justifyContent: "center",
},
@@ -1,20 +1,11 @@
import { describe, expect, it } from "vitest";
import {
ALERT_ICON_SIZE,
bySizing,
CONTROL_HEIGHTS,
controlHeightForLayout,
controlSizingForLayout,
createControlGeometry,
HEADER_CONTROL_HEIGHT,
ICON_BUTTON_SIZES,
iconSizeFor,
resolveControlSizing,
switchGeometryForLayout,
getControlInteractionPhase,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_CONTROL_SIZES,
TOUCH_TARGET,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
@@ -56,52 +47,6 @@ const theme = {
},
} as unknown as Theme;
describe("control sizing gate", () => {
it("is touch-sized in the compact layout or on a touch screen, and pointer-sized otherwise", () => {
expect(resolveControlSizing({ compactLayout: true, touchScreen: true })).toBe("touch");
expect(resolveControlSizing({ compactLayout: true, touchScreen: false })).toBe("touch");
expect(resolveControlSizing({ compactLayout: false, touchScreen: true })).toBe("touch");
expect(resolveControlSizing({ compactLayout: false, touchScreen: false })).toBe("pointer");
});
it("gives a touch screen its touch sizes at every width", () => {
const sizes = { touch: 44, pointer: 32 };
// An iPad: touch-sized in the docked desktop layout too.
expect(bySizing(sizes, true)).toEqual({ xs: 44, md: 44 });
// A mouse-driven window: touch-sized only once it narrows into the compact layout.
expect(bySizing(sizes, false)).toEqual({ xs: 44, md: 32 });
});
it("keeps a device without a touch screen on pointer sizes from md up", () => {
// The unit runtime has no touch screen, which is the desktop that must not change.
expect(controlSizingForLayout(false)).toBe("pointer");
expect(controlSizingForLayout(true)).toBe("touch");
expect(CONTROL_HEIGHTS.md).toEqual({ xs: 44, md: 32 });
expect(CONTROL_HEIGHTS.sm).toEqual({ xs: 32, md: 28 });
expect(HEADER_CONTROL_HEIGHT).toEqual({ xs: 32, md: 26 });
});
it("follows the HIG on touch: a 44pt target, no control under 28pt, larger glyphs", () => {
expect(TOUCH_TARGET).toBe(44);
expect(controlHeightForLayout("md", true)).toBe(TOUCH_TARGET);
expect(ICON_BUTTON_SIZES.large.touch).toBe(TOUCH_TARGET);
expect(ICON_BUTTON_SIZES.small.touch).toBe(TOUCH_TARGET);
// Composer toolbar controls: a full target on touch, unchanged 28 on pointer.
expect(TOOLBAR_CONTROL_SIZES).toEqual({ touch: TOUCH_TARGET, pointer: 28 });
expect(TOOLBAR_CONTROL_HEIGHT).toEqual({ xs: TOUCH_TARGET, md: 28 });
for (const size of ["sm", "md"] as const) {
expect(controlHeightForLayout(size, true)).toBeGreaterThan(
controlHeightForLayout(size, false),
);
expect(controlHeightForLayout(size, false)).toBeGreaterThanOrEqual(28);
}
for (const size of ["xs", "sm", "md", "lg"] as const) {
expect(iconSizeFor(size).touch).toBeGreaterThan(iconSizeFor(size).pointer);
}
});
});
describe("control geometry", () => {
it("keeps resting control borders transparent while preserving border geometry", () => {
const geometry = createControlGeometry(theme);
@@ -153,17 +98,18 @@ describe("control geometry", () => {
const geometry = createControlGeometry(theme);
const borderWidth = theme.borderWidth[1];
expect(geometry.fieldControlSm.minHeight).toEqual({ xs: 32, md: 28 });
expect(geometry.fieldControlSm.minHeight).toBe(28);
expect(
geometry.fieldTextSm.lineHeight +
geometry.fieldControlSm.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlSm.minHeight);
// The default keeps a 44 touch target in the compact layout and is 32 from the md
// breakpoint up.
expect(geometry.fieldControlMd.minHeight).toEqual({ xs: 44, md: 32 });
expect(geometry.fieldControlMd.paddingVertical).toEqual({ xs: 11, md: 5 });
for (const layout of ["xs", "md"] as const) {
expect(
geometry.fieldTextSm.lineHeight +
geometry.fieldControlSm.paddingVertical[layout] * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlSm.minHeight[layout]);
expect(
geometry.fieldTextMd.lineHeight +
geometry.fieldControlMd.paddingVertical[layout] * 2 +
@@ -179,25 +125,24 @@ describe("control geometry", () => {
expect(geometry.segmentedContainerMd.padding).toBe(0);
expect(geometry.segmentedSegmentSm.borderRadius).toBe(geometry.buttonSm.borderRadius);
expect(geometry.segmentedSegmentMd.borderRadius).toBe(geometry.buttonMd.borderRadius);
expect(geometry.segmentedContainerSm.minHeight).toEqual(geometry.buttonSm.minHeight);
expect(geometry.segmentedContainerSm.minHeight).toBe(geometry.buttonSm.minHeight);
expect(geometry.segmentedContainerMd.minHeight).toEqual(geometry.buttonMd.minHeight);
expect(geometry.segmentedSegmentSm.minHeight).toEqual({ xs: 28, md: 24 });
expect(geometry.segmentedSegmentSm.minHeight).toBe(24);
expect(geometry.segmentedSegmentMd.minHeight).toEqual({ xs: 40, md: 28 });
});
it("keeps one size contract across buttons, fields, and segmented controls", () => {
const geometry = createControlGeometry(theme);
expect(geometry.buttonSm.minHeight).toEqual({ xs: 32, md: 28 });
expect(geometry.buttonSm.minHeight).toBe(28);
expect(geometry.buttonMd.minHeight).toEqual({ xs: 44, md: 32 });
expect(geometry.buttonSm.minHeight).toEqual(CONTROL_HEIGHTS.sm);
expect(geometry.buttonSm.minHeight).toBe(CONTROL_HEIGHTS.sm);
expect(geometry.buttonMd.minHeight).toEqual(CONTROL_HEIGHTS.md);
expect(geometry.fieldControlSm.minHeight).toEqual(CONTROL_HEIGHTS.sm);
expect(geometry.fieldControlSm.minHeight).toBe(CONTROL_HEIGHTS.sm);
expect(geometry.fieldControlMd.minHeight).toEqual(CONTROL_HEIGHTS.md);
expect(controlHeightForLayout("md", true)).toBe(44);
expect(controlHeightForLayout("md", false)).toBe(32);
expect(controlHeightForLayout("sm", true)).toBe(32);
expect(controlHeightForLayout("sm", false)).toBe(28);
expect(controlHeightForLayout("sm", true)).toBe(28);
// Same size name means the same label size on every control kind.
expect(geometry.segmentedLabelSm.fontSize).toBe(12);
@@ -232,15 +177,13 @@ describe("control geometry", () => {
thumbTravel: 18,
reservedHeight: 44,
});
// Tight chrome keeps the pointer-sized track on every layout and reserves its row's height.
expect(switchGeometryForLayout("sm", true)).toMatchObject({
trackWidth: 34,
reservedHeight: 32,
});
expect(switchGeometryForLayout("sm", false)).toMatchObject({
trackWidth: 34,
reservedHeight: 28,
});
// Tight chrome keeps the pointer-sized switch on every layout.
for (const isCompact of [true, false]) {
expect(switchGeometryForLayout("sm", isCompact)).toMatchObject({
trackWidth: 34,
reservedHeight: 28,
});
}
});
it("gives alert title and description one font size and a tight gap per size", () => {
@@ -1,14 +1,5 @@
import type { StyleProp, ViewStyle } from "react-native";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
bySizing,
controlSizingForLayout,
mapLayout,
TOUCH_TARGET,
type ControlSizing,
type LayoutValue,
type SizingValue,
} from "./control-sizing";
/**
* Controls come in two sizes. `md` is the default and the right size almost everywhere;
@@ -35,78 +26,46 @@ export interface ControlInteractionStyleMap {
controlDisabled?: StyleProp<ViewStyle>;
}
export {
bySizing,
controlSizingForLayout,
mapLayout,
resolveControlSizing,
TOUCH_TARGET,
type ControlSizing,
type ControlSizingFacts,
type LayoutValue,
type SizingValue,
} from "./control-sizing";
// Touch `md` is the iOS HIG's 44pt default control; touch `sm` stays above its 28pt minimum.
const CONTROL_SIZES = {
sm: { touch: 32, pointer: 28 },
md: { touch: TOUCH_TARGET, pointer: 32 },
} satisfies Record<ControlSize, SizingValue>;
/** Pointer-sized header controls run tighter than `sm` so they fit the 36pt header. */
const POINTER_HEADER_CONTROL_HEIGHT = 26;
const SMALL_CONTROL_HEIGHT = 28;
// The default control keeps a full touch target in the compact layout and tightens where a
// pointer drives it.
const DEFAULT_CONTROL_HEIGHT_COMPACT_LAYOUT = 44;
const DEFAULT_CONTROL_HEIGHT_WIDE_LAYOUT = 32;
export const HEADER_CONTROL_HEIGHT = 26;
const SEGMENTED_INSET = 2;
const CONTROL_FOCUS_RING_WIDTH = 2;
const CONTROL_FOCUS_RING_OFFSET = 1;
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
/** A Unistyles breakpoint value: one number in the compact layout, another from `md` up. */
// A mapped type, not an interface: Unistyles checks breakpoint objects against an index
// signature, which interfaces never satisfy.
export type LayoutValue = Record<"xs" | "md", number>;
function byLayout(compact: number, wide: number): LayoutValue {
return { xs: compact, md: wide };
}
function mapLayout(value: LayoutValue, map: (height: number) => number): LayoutValue {
return byLayout(map(value.xs), map(value.md));
}
/**
* The height of every button, field, and segmented control of a size. Exported so a row that
* hosts one of those controls can size itself from the same numbers instead of guessing a
* height the control then outgrows. Breakpoint values, so they only work inside a Unistyles
* stylesheet.
* height the control then outgrows. `md` depends on the layout, so it only works inside a
* Unistyles stylesheet.
*/
export const CONTROL_HEIGHTS = {
sm: bySizing(CONTROL_SIZES.sm),
md: bySizing(CONTROL_SIZES.md),
} satisfies Record<ControlSize, LayoutValue>;
/** A framed control in a header row, such as a split button. Touch-sized it is an `sm` control. */
export const HEADER_CONTROL_HEIGHT = bySizing({
touch: CONTROL_SIZES.sm.touch,
pointer: POINTER_HEADER_CONTROL_HEIGHT,
});
/**
* Square icon-only buttons. Touch-sized, every icon button is one touch target; pointer-sized,
* header buttons are larger than toolbar buttons.
*/
export const ICON_BUTTON_SIZES = {
large: { touch: TOUCH_TARGET, pointer: POINTER_HEADER_CONTROL_HEIGHT },
small: { touch: TOUCH_TARGET, pointer: 20 },
} satisfies Record<"large" | "small", SizingValue>;
/**
* A control in a dense toolbar row, such as the composer's: an icon-only square or a labelled
* pill. Touch-sized it is one touch target.
*/
export const TOOLBAR_CONTROL_SIZES = { touch: TOUCH_TARGET, pointer: 28 } satisfies SizingValue;
export const TOOLBAR_CONTROL_HEIGHT = bySizing(TOOLBAR_CONTROL_SIZES);
/**
* Glyph sizes. Pointer glyphs sit beside 13-14pt text; touch glyphs sit beside iOS's 17pt body
* text, so each step is larger.
*/
export type IconSize = keyof typeof ICON_SIZE;
export const TOUCH_ICON_SIZE: Record<IconSize, number> = { xs: 14, sm: 18, md: 20, lg: 24 };
export function iconSizeFor(size: IconSize): SizingValue {
return { touch: TOUCH_ICON_SIZE[size], pointer: ICON_SIZE[size] };
}
sm: SMALL_CONTROL_HEIGHT,
md: byLayout(DEFAULT_CONTROL_HEIGHT_COMPACT_LAYOUT, DEFAULT_CONTROL_HEIGHT_WIDE_LAYOUT),
} satisfies Record<ControlSize, number | LayoutValue>;
/** The height of a control of `size`, for code that has to compute with it outside a stylesheet. */
export function controlHeightForLayout(size: ControlSize, isCompact: boolean): number {
return CONTROL_SIZES[size][controlSizingForLayout(isCompact)];
const height = CONTROL_HEIGHTS[size];
if (typeof height === "number") return height;
return isCompact ? height.xs : height.md;
}
export const controlIconSize: Record<ControlSize, number> = {
@@ -137,8 +96,7 @@ const SWITCH_TOUCH = switchSize(44, 26, 22);
* animation, so the size is resolved to plain numbers here instead of a stylesheet breakpoint.
*/
export function switchGeometryForLayout(size: ControlSize, isCompact: boolean): SwitchGeometry {
const touchSized = controlSizingForLayout(isCompact) === "touch";
const track = size === "md" && touchSized ? SWITCH_TOUCH : SWITCH_POINTER;
const track = size === "md" && isCompact ? SWITCH_TOUCH : SWITCH_POINTER;
return { ...track, reservedHeight: controlHeightForLayout(size, isCompact) };
}
@@ -267,8 +225,10 @@ export function createControlGeometry(theme: Theme) {
const fieldControlSm = {
minHeight: CONTROL_HEIGHTS.sm,
paddingHorizontal: theme.spacing[2],
paddingVertical: mapLayout(CONTROL_HEIGHTS.sm, (height) =>
fieldVerticalPadding(height, fieldTextSmLineHeight, controlBorderWidth),
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.sm,
fieldTextSmLineHeight,
controlBorderWidth,
),
borderRadius: theme.borderRadius.md,
};
@@ -298,7 +258,7 @@ export function createControlGeometry(theme: Theme) {
},
buttonMd: {
minHeight: CONTROL_HEIGHTS.md,
paddingHorizontal: bySizing({ touch: theme.spacing[6], pointer: theme.spacing[4] }),
paddingHorizontal: byLayout(theme.spacing[6], theme.spacing[4]),
borderRadius: theme.borderRadius.full,
},
// A button with only an icon is a circle of its size's height.
@@ -354,7 +314,7 @@ export function createControlGeometry(theme: Theme) {
padding: 0,
},
segmentedSegmentSm: {
minHeight: mapLayout(CONTROL_HEIGHTS.sm, (height) => height - SEGMENTED_INSET * 2),
minHeight: CONTROL_HEIGHTS.sm - SEGMENTED_INSET * 2,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.full,
},
@@ -1,68 +0,0 @@
import { hasTouchScreen } from "@/hooks/use-fine-pointer";
// The sizing gate, apart from the control sizes so that low-level modules such as
// `constants/layout` can size by it without loading the theme. Import it through
// `@/components/ui/control-geometry`, which re-exports it.
/**
* Every size comes in a touch value and a pointer value. Touch values follow Apple's Human
* Interface Guidelines for iOS and iPadOS: a 44pt default control and hit region, nothing
* smaller than its 28pt minimum. Pointer values sit one step down the same button ladder.
*/
export type ControlSizing = "touch" | "pointer";
export interface ControlSizingFacts {
/** The layout is below the `md` breakpoint. */
compactLayout: boolean;
/** The device has a touch screen; see `hasTouchScreen`. */
touchScreen: boolean;
}
/**
* The one sizing gate: touch-sized in the compact layout or on any device with a touch screen,
* pointer-sized otherwise. A touch screen keeps touch sizing when a mouse or keyboard is attached.
*/
export function resolveControlSizing({
compactLayout,
touchScreen,
}: ControlSizingFacts): ControlSizing {
if (compactLayout || touchScreen) return "touch";
return "pointer";
}
// Read once, so attaching a mouse or keyboard later never flips the sizing.
const DEVICE_HAS_TOUCH_SCREEN = hasTouchScreen();
/** The sizing on this device for a layout, for code that sizes outside a stylesheet. */
export function controlSizingForLayout(compactLayout: boolean): ControlSizing {
return resolveControlSizing({ compactLayout, touchScreen: DEVICE_HAS_TOUCH_SCREEN });
}
/** A Unistyles breakpoint value: one number in the compact layout, another from `md` up. */
// A mapped type, not an interface: Unistyles checks breakpoint objects against an index
// signature, which interfaces never satisfy.
export type LayoutValue = Record<"xs" | "md", number>;
export type SizingValue = Record<ControlSizing, number>;
/**
* A size as a Unistyles breakpoint value. The compact layout always takes the touch value; from
* `md` up the device decides.
*/
export function bySizing(
sizes: SizingValue,
touchScreen: boolean = DEVICE_HAS_TOUCH_SCREEN,
): LayoutValue {
return {
xs: sizes[resolveControlSizing({ compactLayout: true, touchScreen })],
md: sizes[resolveControlSizing({ compactLayout: false, touchScreen })],
};
}
/** Derives a breakpoint value from another, such as a row as tall as its control plus padding. */
export function mapLayout(value: LayoutValue, map: (height: number) => number): LayoutValue {
return { xs: map(value.xs), md: map(value.md) };
}
/** The smallest touch target, and the size of every touch-sized icon button, row, and bar. */
export const TOUCH_TARGET = 44;
@@ -1,24 +1,17 @@
import { createContext, useCallback, useContext } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import {
bySizing,
controlSizingForLayout,
ICON_BUTTON_SIZES,
TOUCH_ICON_SIZE,
type SizingValue,
} from "@/components/ui/control-geometry";
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { ICON_SIZE } from "@/styles/theme";
export { extraMutedIconColorMapping, mutedIconColorMapping } from "@/components/ui/icon-color";
export type IconButtonChromeSize = "large" | "small";
// Touch-sized, every icon button is one target with one glyph size.
const GLYPH_SIZES = {
large: { touch: TOUCH_ICON_SIZE.md, pointer: ICON_SIZE.md },
small: { touch: TOUCH_ICON_SIZE.md, pointer: ICON_SIZE.sm },
} satisfies Record<IconButtonChromeSize, SizingValue>;
const SMALL_ICON_BUTTON_SIZE = 20;
/** Width and height of a large icon button on compact layouts. */
export const COMPACT_LARGE_ICON_BUTTON_SIZE = 32;
const COMPACT_SMALL_ICON_BUTTON_SIZE = 32;
export interface IconButtonChromeState {
hovered?: boolean;
@@ -29,7 +22,7 @@ export interface IconButtonChromeState {
function resolveIconButtonFrame(size: IconButtonChromeSize, compact: boolean) {
if (size === "large") return styles.large;
return controlSizingForLayout(compact) === "touch" ? styles.smallTouch : styles.small;
return compact ? styles.smallCompact : styles.small;
}
interface IconButtonChromeOptions {
@@ -99,17 +92,24 @@ export function iconButtonChromeFrameStyle(
}
export function iconButtonChromeGlyphSize(size: IconButtonChromeSize, compact = false): number {
return GLYPH_SIZES[size][controlSizingForLayout(compact)];
if (size === "large") return ICON_SIZE.md;
return compact ? 18 : 14;
}
export function smallIconButtonChromeFrameSize(compact = false): number {
return ICON_BUTTON_SIZES.small[controlSizingForLayout(compact)];
return compact ? COMPACT_SMALL_ICON_BUTTON_SIZE : SMALL_ICON_BUTTON_SIZE;
}
const styles = StyleSheet.create((theme) => ({
large: {
width: bySizing(ICON_BUTTON_SIZES.large),
height: bySizing(ICON_BUTTON_SIZES.large),
width: {
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
md: HEADER_CONTROL_HEIGHT,
},
height: {
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
md: HEADER_CONTROL_HEIGHT,
},
padding: 0,
borderRadius: theme.borderRadius.md,
alignItems: "center",
@@ -119,8 +119,8 @@ const styles = StyleSheet.create((theme) => ({
outlineColor: "transparent",
},
small: {
width: ICON_BUTTON_SIZES.small.pointer,
height: ICON_BUTTON_SIZES.small.pointer,
width: SMALL_ICON_BUTTON_SIZE,
height: SMALL_ICON_BUTTON_SIZE,
padding: 0,
borderRadius: theme.borderRadius.md,
alignItems: "center",
@@ -129,9 +129,9 @@ const styles = StyleSheet.create((theme) => ({
outlineWidth: 0,
outlineColor: "transparent",
},
smallTouch: {
width: ICON_BUTTON_SIZES.small.touch,
height: ICON_BUTTON_SIZES.small.touch,
smallCompact: {
width: COMPACT_SMALL_ICON_BUTTON_SIZE,
height: COMPACT_SMALL_ICON_BUTTON_SIZE,
padding: 0,
borderRadius: theme.borderRadius.md,
alignItems: "center",
@@ -1,5 +1,2 @@
/** Shared geometry for menu rows and controls that align with them. */
import { density } from "@/styles/density";
/** A menu row is a tight row: touch-sized wherever controls are (see `resolveControlSizing`). */
export const MENU_ITEM_HEIGHT = density.tightRowHeight;
export const MENU_ITEM_HEIGHT = { xs: 40, md: 28 } as const;
@@ -2,7 +2,6 @@ import { forwardRef, useCallback, type ComponentProps } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { useComposerControlLayout } from "@/composer/agent-controls/layout-context";
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
import type { AgentControlIcon } from "@/agent-controls/icons";
@@ -97,7 +96,7 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
const styles = StyleSheet.create((theme) => ({
toolbarControl: {
height: TOOLBAR_CONTROL_HEIGHT,
height: 28,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
@@ -108,7 +107,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: "transparent",
},
toolbarIconOnly: {
width: TOOLBAR_CONTROL_HEIGHT,
width: 28,
flexShrink: 0,
paddingHorizontal: 0,
justifyContent: "center",
@@ -4,7 +4,7 @@ import { StyleSheet, View } from "react-native";
export function ComposerToolbarGlyph({ children, size }: { children: ReactNode; size: number }) {
return (
<View
style={size >= 20 ? styles.touch : styles.pointer}
style={size >= 20 ? styles.native : styles.web}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
@@ -15,14 +15,14 @@ export function ComposerToolbarGlyph({ children, size }: { children: ReactNode;
}
const styles = StyleSheet.create({
pointer: {
web: {
width: 16,
height: 16,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
touch: {
native: {
width: 20,
height: 20,
flexShrink: 0,
@@ -61,8 +61,6 @@ import {
resolveAgentModelSelection,
} from "@/composer/agent-controls/utils";
import { useIsCompactFormFactor } from "@/constants/layout";
import { controlSizingForLayout, TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { DensityIcon } from "@/components/ui/density-icon";
import { readMeasuredWidth } from "@/hooks/use-container-width";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
@@ -72,10 +70,11 @@ import {
type AgentControlCommandCenterSource,
} from "@/command-center/agent-control-registration";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { isNative } from "@/constants/platform";
import {
composerToolbarGeometry,
resolveComposerControlDensity,
resolveComposerControlPresentation,
resolveComposerToolbarGlyphSize,
type ComposerControlDensity,
type ComposerControlPresentation,
} from "@/composer/agent-controls/layout";
@@ -461,10 +460,6 @@ function buildAgentProviderModels(
return map;
}
function densityFolds(density: ComposerControlDensity): boolean {
return resolveComposerControlPresentation(density).foldControls;
}
function buildOpenChangeHandler(
selector: AgentControlSelector,
setOpenSelector: (next: AgentControlSelector | null) => void,
@@ -508,20 +503,14 @@ function ControlledAgentControls({
modelSelectorServerId = null,
isCompactLayout,
}: ControlledAgentControlsProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor;
// Control sizes follow the window layout, as the toolbar's stylesheet does. A narrow pane in a
// wide window (`isCompactLayout`) changes which labels show, never how large a control is.
const toolbarSizing = controlSizingForLayout(isCompactFormFactor);
const toolbarGeometry = useMemo(() => composerToolbarGeometry(toolbarSizing), [toolbarSizing]);
const { fontScale } = useWindowDimensions();
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
const [openSelector, setOpenSelector] = useState<AgentControlSelector | null>(null);
// A compact composer always folds its controls into the model sheet; the width budget only
// decides whether the model keeps its label.
const minimumDensity: ComposerControlDensity = isCompact ? "compact" : "full";
const initialDensity = minimumDensity;
const initialDensity: ComposerControlDensity = isCompact ? "tight" : "full";
const [density, setDensity] = useState<ComposerControlDensity>(initialDensity);
const densityRef = useRef<ComposerControlDensity>(initialDensity);
const availableWidthRef = useRef(0);
@@ -585,10 +574,10 @@ function ControlledAgentControls({
const presentation = useMemo(() => resolveComposerControlPresentation(density), [density]);
const layoutContextValue = useMemo(
() => ({
glyphSize: toolbarGeometry.glyphSize,
glyphSize: resolveComposerToolbarGlyphSize(isNative ? "native" : "web"),
presentation,
}),
[presentation, toolbarGeometry],
[presentation],
);
const updateDensityForWidth = useCallback(
@@ -597,20 +586,12 @@ function ControlledAgentControls({
availableWidth,
currentDensity: densityRef.current,
controls: controlPresence,
geometry: toolbarGeometry,
minimumDensity,
});
if (nextDensity === densityRef.current) return;
// Folding swaps the toolbar controls for the model sheet and back, so whatever menu or
// sheet was open belonged to controls that are gone.
if (densityFolds(nextDensity) !== densityFolds(densityRef.current)) {
setOpenSelector(null);
setActiveSheet(null);
}
densityRef.current = nextDensity;
setDensity(nextDensity);
},
[controlPresence, minimumDensity, toolbarGeometry],
[controlPresence],
);
const handleLayout = useCallback(
@@ -652,9 +633,10 @@ function ControlledAgentControls({
selected={args.selected}
active={args.active}
onPress={args.onPress}
iconColor={theme.colors.foreground}
/>
),
[],
[theme.colors.foreground],
);
const handleOpenChange = useCallback(
@@ -720,8 +702,8 @@ function ControlledAgentControls({
const handleCloseSheet = useCallback(() => {
setActiveSheet(null);
if (!presentation.foldControls) onDropdownClose?.();
}, [onDropdownClose, presentation.foldControls]);
if (!isCompact) onDropdownClose?.();
}, [isCompact, onDropdownClose]);
const handleSelectThinkingAndClose = useCallback(
(thinkingOptionId: string) => {
@@ -752,7 +734,7 @@ function ControlledAgentControls({
return (
<ComposerControlLayoutProvider value={layoutContextValue}>
<View style={styles.container} onLayout={handleLayout}>
{!presentation.foldControls ? (
{!isCompact ? (
<DesktopAgentControlsContent
provider={provider}
providerOptions={providerOptions}
@@ -839,7 +821,6 @@ function ControlledAgentControls({
renderThinkingOption={renderThinkingOption}
modeControl={modeControl}
glyphSize={layoutContextValue.glyphSize}
showLabels={presentation.showModelLabel}
modelSelectorServerId={modelSelectorServerId}
canSwitchProvider={Boolean(onSelectProviderAndModel)}
/>
@@ -1163,7 +1144,6 @@ interface SheetAgentControlsContentProps {
}) => ReactElement;
modeControl?: AgentModeControlValue | null;
glyphSize: number;
showLabels: boolean;
modelSelectorServerId: string | null;
canSwitchProvider: boolean;
}
@@ -1203,7 +1183,6 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
renderThinkingOption,
modeControl,
glyphSize,
showLabels,
modelSelectorServerId,
canSwitchProvider,
} = props;
@@ -1292,7 +1271,6 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
isRetryingProvider={isRetryingModelProvider}
serverId={modelSelectorServerId}
glyphSize={glyphSize}
showLabels={showLabels}
canSwitchProvider={canSwitchProvider}
>
{sheetControls}
@@ -1551,16 +1529,15 @@ function ThinkingComboboxOption({
selected,
active,
onPress,
iconColor,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
iconColor: string;
}) {
const leadingSlot = useMemo(
() => <DensityIcon icon={ThinkingIcon} size="md" color="foreground" />,
[],
);
const leadingSlot = useMemo(() => <ThinkingIcon size={16} color={iconColor} />, [iconColor]);
return (
<ComboboxItem
label={option.label}
@@ -1976,7 +1953,7 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden",
},
modeBadge: {
height: TOOLBAR_CONTROL_HEIGHT,
height: 28,
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
@@ -1994,8 +1971,8 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0,
},
modeIconBadge: {
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 0,
@@ -1,8 +1,5 @@
import { createContext, useContext, type ReactNode } from "react";
import {
resolveComposerControlPresentation,
type ComposerControlPresentation,
} from "@/composer/agent-controls/layout";
import type { ComposerControlPresentation } from "@/composer/agent-controls/layout";
interface ComposerControlLayoutValue {
glyphSize: number;
@@ -11,7 +8,12 @@ interface ComposerControlLayoutValue {
const DEFAULT_LAYOUT: ComposerControlLayoutValue = {
glyphSize: 16,
presentation: resolveComposerControlPresentation("full"),
presentation: {
showCarets: true,
showThinkingLabel: true,
showModeLabel: true,
aggregateFeatures: false,
},
};
const ComposerControlLayoutContext = createContext(DEFAULT_LAYOUT);
@@ -1,42 +1,30 @@
import { describe, expect, it } from "vitest";
import {
composerToolbarGeometry,
COMPOSER_TOOLBAR_GEOMETRY,
resolveComposerControlDensity,
resolveComposerControlPresentation,
resolveComposerToolbarGlyphSize,
} from "./layout";
const POINTER = composerToolbarGeometry("pointer");
const TOUCH = composerToolbarGeometry("touch");
describe("composer control layout", () => {
it("removes labels, then folds controls, then drops the model label as the toolbar narrows", () => {
it("removes labels in priority order as the toolbar narrows", () => {
expect(resolveComposerControlPresentation("full")).toEqual({
showCarets: true,
showThinkingLabel: true,
showModeLabel: true,
aggregateFeatures: false,
foldControls: false,
showModelLabel: true,
});
expect(resolveComposerControlPresentation("condensed")).toMatchObject({
expect(resolveComposerControlPresentation("condensed")).toEqual({
showCarets: false,
showThinkingLabel: false,
showModeLabel: true,
aggregateFeatures: true,
foldControls: false,
});
expect(resolveComposerControlPresentation("tight")).toMatchObject({
expect(resolveComposerControlPresentation("tight")).toEqual({
showCarets: false,
showThinkingLabel: false,
showModeLabel: false,
foldControls: false,
showModelLabel: true,
});
expect(resolveComposerControlPresentation("compact")).toMatchObject({
foldControls: true,
showModelLabel: true,
});
expect(resolveComposerControlPresentation("iconOnly")).toMatchObject({
foldControls: true,
showModelLabel: false,
aggregateFeatures: true,
});
});
@@ -53,7 +41,6 @@ describe("composer control layout", () => {
resolveComposerControlDensity({
availableWidth: 420,
currentDensity: "full",
geometry: POINTER,
controls,
}),
).toBe("full");
@@ -61,7 +48,6 @@ describe("composer control layout", () => {
resolveComposerControlDensity({
availableWidth: 380,
currentDensity: "full",
geometry: POINTER,
controls,
}),
).toBe("condensed");
@@ -69,7 +55,6 @@ describe("composer control layout", () => {
resolveComposerControlDensity({
availableWidth: 290,
currentDensity: "condensed",
geometry: POINTER,
controls,
}),
).toBe("condensed");
@@ -77,7 +62,6 @@ describe("composer control layout", () => {
resolveComposerControlDensity({
availableWidth: 280,
currentDensity: "condensed",
geometry: POINTER,
controls,
}),
).toBe("tight");
@@ -85,7 +69,6 @@ describe("composer control layout", () => {
resolveComposerControlDensity({
availableWidth: 300,
currentDensity: "tight",
geometry: POINTER,
controls,
}),
).toBe("tight");
@@ -93,7 +76,6 @@ describe("composer control layout", () => {
resolveComposerControlDensity({
availableWidth: 312,
currentDensity: "tight",
geometry: POINTER,
controls,
}),
).toBe("condensed");
@@ -103,7 +85,6 @@ describe("composer control layout", () => {
const base = {
availableWidth: 430,
currentDensity: "condensed" as const,
geometry: POINTER,
};
expect(
@@ -148,7 +129,6 @@ describe("composer control layout", () => {
const base = {
availableWidth: 430,
currentDensity: "full" as const,
geometry: POINTER,
controls: {
hasModel: true,
hasThinking: true,
@@ -174,97 +154,15 @@ describe("composer control layout", () => {
).toBe("condensed");
});
it("gives every toolbar control one shell and one glyph per control sizing", () => {
expect(POINTER).toEqual({
it("gives every toolbar control one shell and one platform glyph envelope", () => {
expect(COMPOSER_TOOLBAR_GEOMETRY).toEqual({
controlSize: 28,
glyphSize: 16,
controlGap: 4,
iconLabelGap: 4,
labelPadding: 8,
caretSize: 14,
});
expect(TOUCH).toMatchObject({ controlSize: 44, glyphSize: 20 });
});
it("budgets touch-sized controls, so a touch toolbar condenses at a width a pointer one fits", () => {
const input = {
availableWidth: 420,
currentDensity: "full" as const,
controls: {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" as const }],
fontScale: 1,
},
};
expect(resolveComposerControlDensity({ ...input, geometry: POINTER })).toBe("full");
expect(resolveComposerControlDensity({ ...input, geometry: TOUCH })).toBe("condensed");
});
describe("touch toolbar on an iPad in portrait, sidebar docked", () => {
// Codex: model, thinking, mode and two toggle features. The measured toolbar is 272 wide
// with an empty draft and 224 once an attachment brings in the send button.
const controls = {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" as const }, { type: "toggle" as const }],
fontScale: 1,
};
it("keeps the model family label with icon-only controls on an empty draft", () => {
expect(
resolveComposerControlDensity({
availableWidth: 272,
currentDensity: "full",
geometry: TOUCH,
controls,
}),
).toBe("tight");
});
it("folds thinking, mode and features into the model control once an attachment narrows it", () => {
expect(
resolveComposerControlDensity({
availableWidth: 224,
currentDensity: "tight",
geometry: TOUCH,
controls,
}),
).toBe("compact");
});
it("drops the model label only after the controls are folded", () => {
expect(
resolveComposerControlDensity({
availableWidth: 120,
currentDensity: "compact",
geometry: TOUCH,
controls,
}),
).toBe("iconOnly");
});
});
it("starts a compact composer at the folded toolbar however wide it is", () => {
const input = {
currentDensity: "compact" as const,
geometry: TOUCH,
minimumDensity: "compact" as const,
controls: {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [],
fontScale: 1,
},
};
expect(resolveComposerControlDensity({ ...input, availableWidth: 600 })).toBe("compact");
// The iPhone toolbar with an attachment: "GPT-6…" beside "Medium" still fits.
expect(resolveComposerControlDensity({ ...input, availableWidth: 159 })).toBe("compact");
expect(resolveComposerControlDensity({ ...input, availableWidth: 120 })).toBe("iconOnly");
expect(resolveComposerToolbarGlyphSize("web")).toBe(16);
expect(resolveComposerToolbarGlyphSize("native")).toBe(20);
});
});
@@ -1,23 +1,4 @@
import {
iconSizeFor,
TOOLBAR_CONTROL_SIZES,
type ControlSizing,
} from "@/components/ui/control-geometry";
/**
* How much of the toolbar shows, widest first. Each step drops something before the next:
* carets and labels, then the mode label, then the thinking, mode and feature controls fold into
* the model control's sheet (`compact`, the phone toolbar), then the model's own label.
*/
export type ComposerControlDensity = "full" | "condensed" | "tight" | "compact" | "iconOnly";
const DENSITY_LADDER: readonly ComposerControlDensity[] = [
"full",
"condensed",
"tight",
"compact",
"iconOnly",
];
export type ComposerControlDensity = "full" | "condensed" | "tight";
export interface ComposerControlPresence {
hasModel: boolean;
@@ -34,46 +15,27 @@ export interface ComposerControlPresentation {
showThinkingLabel: boolean;
showModeLabel: boolean;
aggregateFeatures: boolean;
/** Thinking, mode and features live in the model control's sheet. */
foldControls: boolean;
showModelLabel: boolean;
}
export interface ComposerToolbarGeometry {
controlSize: number;
glyphSize: number;
controlGap: number;
iconLabelGap: number;
labelPadding: number;
caretSize: number;
}
/** The toolbar's measurements under a control sizing; see `resolveControlSizing`. */
export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarGeometry {
return {
controlSize: TOOLBAR_CONTROL_SIZES[sizing],
glyphSize: iconSizeFor("md")[sizing],
controlGap: 4,
iconLabelGap: 4,
labelPadding: 8,
caretSize: 14,
};
}
export const COMPOSER_TOOLBAR_GEOMETRY = {
controlSize: 28,
controlGap: 4,
iconLabelGap: 4,
labelPadding: 8,
caretSize: 14,
} as const;
const DENSITY_HYSTERESIS = 12;
/** The model label's narrowest useful width: enough for the model family, such as "GPT-6…". */
const MODEL_FAMILY_LABEL_WIDTH = 56;
/** A thinking label beside the folded model label, such as "Medium". */
const THINKING_LABEL_WIDTH = 48;
function normalizedFontScale(fontScale: number): number {
return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1;
}
function sumControlWidths(widths: number[], geometry: ComposerToolbarGeometry): number {
function sumControlWidths(widths: number[]): number {
if (widths.length === 0) return 0;
return (
widths.reduce((total, width) => total + width, 0) + (widths.length - 1) * geometry.controlGap
widths.reduce((total, width) => total + width, 0) +
(widths.length - 1) * COMPOSER_TOOLBAR_GEOMETRY.controlGap
);
}
@@ -81,173 +43,92 @@ function estimateLabelWidth(label: string, fontScale: number): number {
return Array.from(label).length * 7 * fontScale;
}
/** A labelled control's fixed chrome: its padding, its glyph, and the gap to its label. */
function labelledChrome(geometry: ComposerToolbarGeometry): number {
return geometry.labelPadding * 2 + geometry.glyphSize + geometry.iconLabelGap;
}
function resolveFeatureControlWidth(
feature: ComposerFeatureControlPresence,
fontScale: number,
geometry: ComposerToolbarGeometry,
): number {
if (feature.type === "toggle") return geometry.controlSize;
if (feature.type === "toggle") return COMPOSER_TOOLBAR_GEOMETRY.controlSize;
return (
geometry.controlSize +
geometry.iconLabelGap +
geometry.labelPadding * 2 +
COMPOSER_TOOLBAR_GEOMETRY.controlSize +
COMPOSER_TOOLBAR_GEOMETRY.iconLabelGap +
COMPOSER_TOOLBAR_GEOMETRY.labelPadding * 2 +
estimateLabelWidth(feature.label, fontScale)
);
}
function resolveCondensedFloor(
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
function resolveCondensedFloor(controls: ComposerControlPresence): number {
const fontScale = normalizedFontScale(controls.fontScale);
const chrome = labelledChrome(geometry);
const widths: number[] = [];
if (controls.hasModel) widths.push(chrome + 60 * fontScale);
if (controls.hasThinking) widths.push(geometry.controlSize);
if (controls.hasMode) widths.push(chrome + 96 * fontScale);
if (controls.features.length > 0) widths.push(geometry.controlSize);
return sumControlWidths(widths, geometry);
if (controls.hasModel) widths.push(36 + 60 * fontScale);
if (controls.hasThinking) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
if (controls.hasMode) widths.push(36 + 96 * fontScale);
if (controls.features.length > 0) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
return sumControlWidths(widths);
}
function resolveFullFloor(
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
function resolveFullFloor(controls: ComposerControlPresence): number {
const fontScale = normalizedFontScale(controls.fontScale);
const chrome = labelledChrome(geometry);
const caret = geometry.caretSize;
const widths: number[] = [];
if (controls.hasModel) widths.push(chrome + caret + 70 * fontScale);
if (controls.hasThinking) widths.push(chrome + geometry.iconLabelGap + caret + 48 * fontScale);
if (controls.hasMode) widths.push(chrome + geometry.iconLabelGap + caret + 96 * fontScale);
if (controls.hasModel) widths.push(50 + 70 * fontScale);
if (controls.hasThinking) widths.push(54 + 48 * fontScale);
if (controls.hasMode) widths.push(54 + 96 * fontScale);
for (const feature of controls.features) {
widths.push(resolveFeatureControlWidth(feature, fontScale, geometry));
widths.push(resolveFeatureControlWidth(feature, fontScale));
}
return sumControlWidths(widths, geometry);
return sumControlWidths(widths);
}
/** Model at its family label, with thinking, mode and features as icon-only squares. */
function resolveTightFloor(
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
const fontScale = normalizedFontScale(controls.fontScale);
const widths: number[] = [];
if (controls.hasModel) {
widths.push(labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale);
}
if (controls.hasThinking) widths.push(geometry.controlSize);
if (controls.hasMode) widths.push(geometry.controlSize);
if (controls.features.length > 0) widths.push(geometry.controlSize);
return sumControlWidths(widths, geometry);
}
/** One model control carrying the model family label and the thinking label. */
function resolveCompactFloor(
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
const fontScale = normalizedFontScale(controls.fontScale);
const thinkingLabel = controls.hasThinking
? geometry.iconLabelGap + THINKING_LABEL_WIDTH * fontScale
: 0;
return labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale + thinkingLabel;
}
function resolveDensityFloor(
density: ComposerControlDensity,
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
switch (density) {
case "full":
return resolveFullFloor(controls, geometry);
case "condensed":
return resolveCondensedFloor(controls, geometry);
case "tight":
return resolveTightFloor(controls, geometry);
case "compact":
return resolveCompactFloor(controls, geometry);
case "iconOnly":
return 0;
}
}
/**
* The widest density whose floor fits `availableWidth`. Widening past the current density needs
* the floor plus the hysteresis and keeping it tolerates the floor minus it, so a width near a
* floor does not flip back and forth. `minimumDensity` is the widest density allowed.
*/
export function resolveComposerControlDensity(input: {
availableWidth: number;
currentDensity: ComposerControlDensity;
controls: ComposerControlPresence;
geometry: ComposerToolbarGeometry;
minimumDensity?: ComposerControlDensity;
}): ComposerControlDensity {
const current = DENSITY_LADDER.indexOf(input.currentDensity);
const first = DENSITY_LADDER.indexOf(input.minimumDensity ?? "full");
for (let step = first; step < DENSITY_LADDER.length; step += 1) {
const density = DENSITY_LADDER[step];
const floor = resolveDensityFloor(density, input.controls, input.geometry);
let required = floor;
if (step < current) required = floor + DENSITY_HYSTERESIS;
if (step === current) required = floor - DENSITY_HYSTERESIS;
if (input.availableWidth >= required) return density;
}
return "iconOnly";
}
const fullFloor = resolveFullFloor(input.controls);
const condensedFloor = resolveCondensedFloor(input.controls);
const PRESENTATIONS: Record<ComposerControlDensity, ComposerControlPresentation> = {
full: {
showCarets: true,
showThinkingLabel: true,
showModeLabel: true,
aggregateFeatures: false,
foldControls: false,
showModelLabel: true,
},
condensed: {
showCarets: false,
showThinkingLabel: false,
showModeLabel: true,
aggregateFeatures: true,
foldControls: false,
showModelLabel: true,
},
tight: {
showCarets: false,
showThinkingLabel: false,
showModeLabel: false,
aggregateFeatures: true,
foldControls: false,
showModelLabel: true,
},
compact: {
showCarets: false,
showThinkingLabel: true,
showModeLabel: false,
aggregateFeatures: true,
foldControls: true,
showModelLabel: true,
},
iconOnly: {
showCarets: false,
showThinkingLabel: false,
showModeLabel: false,
aggregateFeatures: true,
foldControls: true,
showModelLabel: false,
},
};
if (input.currentDensity === "full") {
if (input.availableWidth >= fullFloor - DENSITY_HYSTERESIS) return "full";
return input.availableWidth >= condensedFloor ? "condensed" : "tight";
}
if (input.currentDensity === "condensed") {
if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full";
if (input.availableWidth < condensedFloor - DENSITY_HYSTERESIS) return "tight";
return "condensed";
}
if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full";
if (input.availableWidth >= condensedFloor + DENSITY_HYSTERESIS) return "condensed";
return "tight";
}
export function resolveComposerControlPresentation(
density: ComposerControlDensity,
): ComposerControlPresentation {
return PRESENTATIONS[density];
if (density === "full") {
return {
showCarets: true,
showThinkingLabel: true,
showModeLabel: true,
aggregateFeatures: false,
};
}
if (density === "condensed") {
return {
showCarets: false,
showThinkingLabel: false,
showModeLabel: true,
aggregateFeatures: true,
};
}
return {
showCarets: false,
showThinkingLabel: false,
showModeLabel: false,
aggregateFeatures: true,
};
}
export function resolveComposerToolbarGlyphSize(platform: "web" | "native"): number {
return platform === "native" ? 20 : 16;
}
@@ -27,7 +27,6 @@ import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import type { AgentMode } from "@getpaseo/protocol/agent-types";
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import { getAgentModeIcon, getAgentModeOptionIcon } from "@/agent-controls/icons";
import { DensityIcon } from "@/components/ui/density-icon";
interface ModeComboboxOptionProps {
option: ComboboxOption;
selected: boolean;
@@ -35,6 +34,7 @@ interface ModeComboboxOptionProps {
onPress: () => void;
provider: string;
providerDefinitions: AgentProviderDefinition[];
iconColor: string;
}
function ModeComboboxOption({
@@ -44,12 +44,12 @@ function ModeComboboxOption({
onPress,
provider,
providerDefinitions,
iconColor,
}: ModeComboboxOptionProps) {
const IconComponent = getAgentModeOptionIcon(provider, option.id, providerDefinitions);
const leadingSlot = useMemo(
() =>
IconComponent ? <DensityIcon icon={IconComponent} size="md" color="foreground" /> : null,
[IconComponent],
() => (IconComponent ? <IconComponent size={16} color={iconColor} /> : null),
[IconComponent, iconColor],
);
return (
<ComboboxItem
@@ -171,9 +171,10 @@ export function AgentModeControl({
onPress={args.onPress}
provider={provider}
providerDefinitions={providerDefinitions}
iconColor={theme.colors.foreground}
/>
),
[provider, providerDefinitions],
[provider, providerDefinitions, theme.colors.foreground],
);
const sheetHeader = useMemo<SheetHeader>(
@@ -15,7 +15,6 @@ import { resolveModelSheetOpening } from "@/composer/agent-controls/model-sheet-
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
const SNAP_POINTS = ["80%", "90%"];
const MODEL_LIST_TOP_INSET = 4;
@@ -43,8 +42,6 @@ interface CompactModelSheetProps {
disabled?: boolean;
serverId?: string | null;
glyphSize: number;
/** False when the toolbar is too narrow for labels: the trigger shows the provider glyph only. */
showLabels: boolean;
canSwitchProvider: boolean;
children: ReactNode;
}
@@ -97,7 +94,6 @@ export function CompactModelSheet({
disabled = false,
serverId = null,
glyphSize,
showLabels,
canSwitchProvider,
children,
}: CompactModelSheetProps) {
@@ -233,16 +229,14 @@ export function CompactModelSheet({
open();
}, [close, isOpen, open]);
const iconOnly = !showLabels && hasSelectedProvider;
const triggerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType) => [
styles.trigger,
iconOnly && styles.triggerIconOnly,
hovered && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
],
[disabled, iconOnly, isOpen],
[disabled, isOpen],
);
const mobileRootContent = useMemo(
() => (
@@ -286,18 +280,16 @@ export function CompactModelSheet({
<ModelIcon size={glyphSize} color={styles.providerIcon.color} />
</ComposerToolbarGlyph>
) : null}
{!iconOnly ? (
<View style={styles.triggerLabels}>
<Text style={styles.triggerText} numberOfLines={1}>
{shortModelLabel(rootBrowser.triggerLabel)}
<View style={styles.triggerLabels}>
<Text style={styles.triggerText} numberOfLines={1}>
{shortModelLabel(rootBrowser.triggerLabel)}
</Text>
{thinkingLabel ? (
<Text style={styles.triggerThinking} numberOfLines={1}>
{thinkingLabel}
</Text>
{thinkingLabel ? (
<Text style={styles.triggerThinking} numberOfLines={1}>
{thinkingLabel}
</Text>
) : null}
</View>
) : null}
) : null}
</View>
</ComboboxTrigger>
<AdaptiveModalSheet
@@ -382,7 +374,7 @@ export function CompactModelSheet({
const styles = StyleSheet.create((theme) => ({
trigger: {
height: TOOLBAR_CONTROL_HEIGHT,
height: 28,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
@@ -392,12 +384,6 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius["2xl"],
backgroundColor: "transparent",
},
triggerIconOnly: {
width: TOOLBAR_CONTROL_HEIGHT,
flexShrink: 0,
paddingHorizontal: 0,
justifyContent: "center",
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
},
+14 -14
View File
@@ -31,11 +31,6 @@ import {
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
controlSizingForLayout,
iconSizeFor,
TOOLBAR_CONTROL_HEIGHT,
} from "@/components/ui/control-geometry";
import { usePanelStore } from "@/stores/panel-store";
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
import { useShallow } from "zustand/shallow";
@@ -198,6 +193,10 @@ const EMPTY_ATTACHMENT_SCOPE_KEYS: readonly string[] = [];
function noop() {}
const noopCallback = () => {};
function resolveComposerButtonIconSize(): number {
return isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
}
function resolveIsComposerLocked(
submitBehavior: "clear" | "preserve-and-lock",
isSubmitLoading: boolean,
@@ -1282,6 +1281,7 @@ function ComposerContentImpl({
}: ComposerContentProps) {
const mode = resolveComposerInputMode(inputMode);
const { t } = useTranslation();
const buttonIconSize = resolveComposerButtonIconSize();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
@@ -1310,7 +1310,6 @@ function ComposerContentImpl({
const isCompactFormFactor = useIsCompactFormFactor();
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
const buttonIconSize = iconSizeFor("md")[controlSizingForLayout(isCompactFormFactor)];
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
const hasFinePointer = useHasFinePointer();
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
@@ -2082,6 +2081,7 @@ function ComposerContentImpl({
],
);
const contextWindowMeterGlyphSize = isCompactLayout ? ICON_SIZE.md : buttonIconSize;
const beforeVoiceContent = useMemo(
() =>
hasAgent ? (
@@ -2092,7 +2092,7 @@ function ComposerContentImpl({
maxTokens={agentState.contextWindowMaxTokens}
usedTokens={agentState.contextWindowUsedTokens}
totalCostUsd={agentState.totalCostUsd}
glyphSize={buttonIconSize}
glyphSize={contextWindowMeterGlyphSize}
/>
</View>
) : null,
@@ -2103,7 +2103,7 @@ function ComposerContentImpl({
agentState.contextWindowMaxTokens,
agentState.contextWindowUsedTokens,
agentState.totalCostUsd,
buttonIconSize,
contextWindowMeterGlyphSize,
],
);
@@ -2598,8 +2598,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
gap: theme.spacing[3],
},
cancelButton: {
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.red[600],
alignItems: "center",
@@ -2612,15 +2612,15 @@ const styles = StyleSheet.create((theme: Theme) => ({
gap: theme.spacing[1],
},
contextWindowMeterSlot: {
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
width: 28,
height: 28,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
realtimeVoiceButton: {
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
+11 -18
View File
@@ -27,12 +27,7 @@ import {
} from "react";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme";
import {
controlSizingForLayout,
iconSizeFor,
TOOLBAR_CONTROL_HEIGHT,
} from "@/components/ui/control-geometry";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import * as Haptics from "expo-haptics";
import { useDictation } from "@/hooks/use-dictation";
@@ -1287,8 +1282,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const isFullscreen = useComposerFullscreen(editingSession);
const [renderedLines, setRenderedLines] = useState(1);
const maxInputHeight = resolveMaxInputHeight(windowHeight, isCompact);
const controlSizing = controlSizingForLayout(isCompact);
const buttonIconSize = iconSizeFor("md")[controlSizing];
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
const toast = useToast();
const voice = useVoiceOptional();
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
@@ -2241,7 +2235,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
/>
<ComposerInputPresentation
fullscreen={isFullscreen}
controlSizing={controlSizing}
sendIconSize={buttonIconSize}
surface={textSurface}
toolbar={toolbar}
@@ -2391,21 +2384,21 @@ const styles = StyleSheet.create((theme: Theme) => ({
gap: theme.spacing[1],
},
attachButton: {
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
attachButtonAnchor: {
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
},
voiceButton: {
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
@@ -2414,8 +2407,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
backgroundColor: theme.colors.destructive,
},
sendButton: {
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
alignItems: "center",
@@ -2424,7 +2417,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
},
sendButtonLabeled: {
width: "auto",
minWidth: TOOLBAR_CONTROL_HEIGHT,
minWidth: 28,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.full,
},
@@ -6,16 +6,14 @@ import { Button } from "@/components/ui/button";
import { ComposerFullscreenBody, ComposerFullscreenFooter } from "@/composer/dock";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { isWeb } from "@/constants/platform";
import {
iconSizeFor,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_CONTROL_SIZES,
type ControlSizing,
} from "@/components/ui/control-geometry";
import { ICON_SIZE } from "@/styles/theme";
import { StyleSheet } from "react-native-unistyles";
import { useComposerFullscreenExit } from "./fullscreen-exit";
import { resolveGlyphRailOffset } from "./state";
const CONTROL_SIZE = 28;
const FULLSCREEN_GLYPH_SIZE = ICON_SIZE.md;
function keepEditorFocused(event: import("react-native").PointerEvent) {
if (isWeb) event.preventDefault();
}
@@ -29,7 +27,6 @@ function keepEditorFocused(event: import("react-native").PointerEvent) {
*/
export function ComposerInputPresentation({
fullscreen,
controlSizing,
sendIconSize,
surface,
toolbar,
@@ -45,8 +42,6 @@ export function ComposerInputPresentation({
overlay,
}: {
fullscreen: boolean;
/** The sizing the toolbar's stylesheet resolves to; see `resolveControlSizing`. */
controlSizing: ControlSizing;
sendIconSize: number;
surface: ReactNode;
toolbar: ReactNode;
@@ -66,19 +61,18 @@ export function ComposerInputPresentation({
// action (Escape, hardware back) belongs only to the pane the user is in.
const { isActiveComposer } = useComposerKeyboardScope();
const exitScopeRef = useComposerFullscreenExit(fullscreen && isActiveComposer, onExit);
const glyphSize = iconSizeFor("md")[controlSizing];
const railOffset = resolveGlyphRailOffset({
controlSize: TOOLBAR_CONTROL_SIZES[controlSizing],
controlSize: CONTROL_SIZE,
sendIconSize,
glyphIconSize: glyphSize,
glyphIconSize: FULLSCREEN_GLYPH_SIZE,
});
const maximizeIcon = useCallback(
(color: string) => <Maximize2 color={color} size={glyphSize} />,
[glyphSize],
(color: string) => <Maximize2 color={color} size={FULLSCREEN_GLYPH_SIZE} />,
[],
);
const minimizeIcon = useCallback(
(color: string) => <Minimize2 color={color} size={glyphSize} />,
[glyphSize],
(color: string) => <Minimize2 color={color} size={FULLSCREEN_GLYPH_SIZE} />,
[],
);
// The toolbar row bleeds 6px past the input padding; the inline glyph sits on that edge.
const enterStyle = useMemo(() => [styles.enterButton, { right: -6 + railOffset }], [railOffset]);
@@ -147,8 +141,8 @@ const styles = StyleSheet.create((theme) => ({
inline: { flexShrink: 1, position: "relative" },
inlineTextSurface: { flexShrink: 1, position: "relative" },
iconButton: {
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
width: CONTROL_SIZE,
height: CONTROL_SIZE,
padding: 0,
borderWidth: 0,
borderRadius: theme.borderRadius.full,
@@ -156,8 +150,8 @@ const styles = StyleSheet.create((theme) => ({
enterButton: {
position: "absolute",
top: 0,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
width: CONTROL_SIZE,
height: CONTROL_SIZE,
padding: 0,
borderWidth: 0,
borderRadius: theme.borderRadius.full,
+4 -6
View File
@@ -13,7 +13,6 @@ import {
type MenuTriggerState,
} from "@/components/ui/menu";
import { StatusRing } from "@/components/status-ring";
import { bySizing } from "@/components/ui/control-geometry";
import { STATUS_RING_HALO_INSET } from "@/components/status-ring/geometry";
import { isWeb } from "@/constants/platform";
import { getStatusDotColor } from "@/utils/status-dot-color";
@@ -344,17 +343,16 @@ const styles = StyleSheet.create((theme) => {
// The rail every panel row sits on: inset from the panel edge so the fill is a rounded block
// inside it, and tall enough that revealing an action button cannot resize the row.
//
// Touch and pointer sizing differ here and only here. A finger needs the row a menu row gets
// on touch (`MENU_ITEM_HEIGHT` is always its touch value at `xs`); a pointer does not, and the
// popover keeps its density.
// Compact and desktop differ here and only here. A finger needs the taller row a menu row gets
// on compact; a pointer does not, and the popover keeps its density.
const trackRow = {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: bySizing({ touch: MENU_ITEM_HEIGHT.xs, pointer: 32 }),
minHeight: { xs: MENU_ITEM_HEIGHT.xs, md: 32 },
marginHorizontal: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: bySizing({ touch: theme.spacing[2], pointer: theme.spacing[1] }),
paddingVertical: { xs: theme.spacing[2], md: theme.spacing[1] },
borderRadius: theme.borderRadius.md,
} as const;
+3 -8
View File
@@ -1,19 +1,14 @@
import { useUnistyles } from "react-native-unistyles";
import { bySizing, TOUCH_TARGET } from "@/components/ui/control-sizing";
import { isWeb } from "@/constants/platform";
export const FOOTER_HEIGHT = 75;
// A bar of icon buttons: touch-sized it is one touch target tall, like an iOS navigation bar.
const BAR_HEIGHT = { touch: TOUCH_TARGET, pointer: 36 };
// Shared header inner height from `md` up (excluding safe area insets and border).
// Shared header inner height (excluding safe area insets and border)
// Used by both agent header (ScreenHeader) and explorer sidebar header
// This ensures both headers have the same visual height
export const HEADER_INNER_HEIGHT = bySizing(BAR_HEIGHT).md;
export const HEADER_INNER_HEIGHT = 36;
export const HEADER_INNER_HEIGHT_MOBILE = 56;
/** The tab rail and pane toolbars under the header. A breakpoint value for stylesheets. */
export const WORKSPACE_SECONDARY_HEADER_HEIGHT = bySizing(BAR_HEIGHT);
export const WORKSPACE_SECONDARY_HEADER_HEIGHT = 36;
export const HEADER_TOP_PADDING_MOBILE = 8;
// Measured composer width; independent of the window's compact/wide breakpoint.
+21 -6
View File
@@ -17,7 +17,7 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
import { useGitActionRunner } from "@/git/use-actions";
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { CONTROL_HEIGHTS, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
interface GitActionsSplitButtonProps {
gitActions: GitActions;
@@ -272,7 +272,10 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0,
},
splitButton: {
height: HEADER_CONTROL_HEIGHT,
height: {
xs: CONTROL_HEIGHTS.sm,
md: HEADER_CONTROL_HEIGHT,
},
flexDirection: "row",
alignItems: "stretch",
borderRadius: theme.borderRadius.md,
@@ -293,7 +296,10 @@ const styles = StyleSheet.create((theme) => ({
xs: 48,
md: 42,
},
height: HEADER_CONTROL_HEIGHT,
height: {
xs: CONTROL_HEIGHTS.sm,
md: HEADER_CONTROL_HEIGHT,
},
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
@@ -324,15 +330,24 @@ const styles = StyleSheet.create((theme) => ({
transform: [{ scale: 0.8 }],
},
splitButtonCaret: {
width: HEADER_CONTROL_HEIGHT,
width: {
xs: CONTROL_HEIGHTS.sm,
md: HEADER_CONTROL_HEIGHT,
},
alignItems: "center",
justifyContent: "center",
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.borderAccent,
},
iconButton: {
width: HEADER_CONTROL_HEIGHT,
height: HEADER_CONTROL_HEIGHT,
width: {
xs: CONTROL_HEIGHTS.sm,
md: HEADER_CONTROL_HEIGHT,
},
height: {
xs: CONTROL_HEIGHTS.sm,
md: HEADER_CONTROL_HEIGHT,
},
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
@@ -2,7 +2,7 @@ import React, { type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { CountedCheckPresentation } from "@/git/check-presentation";
import { CONTROL_HEIGHTS, mapLayout } from "@/components/ui/control-geometry";
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
import {
CheckPresentationIcon,
getCheckPresentationTone,
@@ -164,10 +164,10 @@ export const sectionKitStyles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
// Fixed, not minHeight plus padding. A row's trailing slot holds an sm Button
// , which stacked on the vertical padding and made rows with an
// (28pt), which stacked on the vertical padding and made rows with an
// "Add to chat" button taller than rows without one. The row is single-line by
// construction, so it declares a height the button fits inside instead.
height: mapLayout(CONTROL_HEIGHTS.sm, (height) => height + theme.spacing[1]),
height: CONTROL_HEIGHTS.sm + theme.spacing[1],
},
checkName: {
fontSize: theme.fontSize.base,

Some files were not shown because too many files have changed in this diff Show More