Skip to content

fix(ui): restore the prompt anchor rail and fix its hover and jump - #2923

Merged
Astro-Han merged 3 commits into
apache:mainfrom
ARE404:are404/fix-prompt-rail-tick-bar
Aug 13, 2026
Merged

fix(ui): restore the prompt anchor rail and fix its hover and jump#2923
Astro-Han merged 3 commits into
apache:mainfrom
ARE404:are404/fix-prompt-rail-tick-bar

Conversation

@ARE404

@ARE404 ARE404 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

The prompt anchor rail (#563) has been invisible since 0.1.9. #2580 moved its tick onto Astryx's Button, which wraps children in its own label span — the bar the tick draws went from being a direct child of the flex tick (blockified, 14–23×3) to an inline box in normal flow, and an inline box takes no width or height. Every bar computed to 0×0. The rail, its ticks and its click targets were all still there, painting nothing.

Measured on the new fixture at 1280×800: the rail's box is 8px wide — its own padding, with the ticks contributing nothing — and 22px again with display: block on the bar.

Using it once it was back turned up four more things, all fixed here:

Before After
Hover falloff Dropped out in the 4px gap between ticks Hit boxes tile; the pitch is unchanged
Preview card 300ms of nothing before it opened (Astryx's default) 120ms
Jump highlight Glided 280ms across the rail, then hopped along with the scroll Switches once, instantly, and holds
First click of a session Did nothing until the reader scrolled by hand Lands and holds

The first-click bug is a collision, not a defect on either side

Worth reading even if the rest is uncontroversial, because it will bite anything else that navigates a transcript programmatically.

useChatStreamScroll keeps the transcript pinned to the bottom while a turn streams, and unlocks when the reader scrolls up — detected by comparing scrollTop across scroll events, which covers wheel, touch, scrollbar drag and keyboard alike. To avoid misreading Chrome's synthetic scroll events (fired when content resizes), it ignores any scroll event that arrives with a changed scrollHeight or offsetHeight:

if (scrollHeightChanged || offsetHeightChanged) {
  // Synthetic scroll from resize — don't change lock state
  lastScrollTopRef.current = scrollTop;
  return;
}

Both halves are reasonable. But a rail jump into a turn the progressive mount (#2191) hasn't reached has to mount it first, and the fill that follows changes scrollHeight for several frames. The jump's own scroll therefore arrives with a changed height every time and is discarded: the lock stays on, scrollIfLocked pulls the transcript back to the bottom, and the click reads as dead. A wheel gesture broke it because onWheel takes a separate path — which is exactly why scrolling by hand "fixed" it.

holdJumpDestination re-aims at the target on each height change until the fill stops. The last of those scrolls lands with a stable height, and that is the one the lock finally reads as a scroll up. Measured on the 30-prompt fixture: clicking the first tick landed at scrollTop 7042 (the bottom) before, and 24 after.

This is the cheapest place to absorb it, not the right one. The real fix is an entry point on Astryx's side for "this is programmatic navigation, release the lock" — happy to take that upstream if you agree with the framing.

Coverage

The e2e suite for this rail was deleted in #2462 as low-value, and the multi-prompt fixtures it ran on in #2656. Since then the rail has failed twice more, both times by rendering and not painting, and once all the way into a release. So this adds back the smallest thing that closes the gap — five tests where the deleted suite had nine:

Two honest limits:

  • The first-click e2e case is a path check, not a guard. Whether the lock wins depends on which frame the fill lands on relative to a smooth scroll still in flight; it goes green against the unfixed renderer often enough to be useless as a guard. The unit test is what holds that behaviour. Both are commented as such.
  • The motion cannot be tested here at all. Fixture renders carry data-maka-e2e-fixture, and base.css gives that animation: none plus a 0.01ms transition cap, so a fixture's state never depends on when it settles. These tests assert end states only.

prompt-rail.spec.ts is also load-bearing on macOS specifically: the overlay scrollbar takes no layout space but still intercepts the pointer, so the #2338 class of regression goes green on Linux CI. Worth running on a Mac before merging anything that touches the rail's right edge.

Validation

  • npm run format:check, npm --workspace @maka/desktop run typecheck (all four tsconfigs), node scripts/check-astryx-alignment.mjs
  • Unit: core 538, ui 126, desktop 767 — all passing
  • E2E: full suite green except a slash-command-menu flake that passes on re-run and is unrelated to this change; prompt-rail.spec.ts 5/5
  • Verified the tick-bar test fails on the unfixed renderer with Expected: > 0, Received: 0, and the gapless-travel test with Expected: 0, Received: 28
  • Ran the built app against a real profile to check the hover, the preview timing, the jump and the first click by hand

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for bringing the prompt rail back and for documenting the rendering regression so carefully. The display: block fix, continuous tick hit areas, and the real-layout coverage all look well targeted.

I found two P2 issues around the first-click workaround, described inline. They share one underlying cause: Astryx owns the transcript’s auto-follow lock, while the rail now temporarily becomes a second scroll authority through a ref, an RAF loop, and a fixed 700 ms timeout. That makes completion depend on timing rather than the actual navigation and progressive-mount lifecycle.

My preferred direction would be to expose a first-class “programmatic navigation / unlock auto-follow” seam from ChatLayout, then let progressive mount only ensure that the target exists before navigating through that owner. That should make it possible to delete holdJumpDestination, its scheduler seam, and the fixed timeout. If changing the Astryx seam is too broad for this PR, a smaller fix would be to restart the hold for every click, end it on the real progressive-fill/warm-up boundary, and cancel it immediately on wheel, touch, or scrollbar interaction.

The 30-prompt fixture and the layout-oriented E2E cases look justified. I would only replace the current first-click E2E, since it does not currently verify the clicked turn or exercise the production smooth-scroll path.

Since this is a material visual and interaction change, could you also add a real app or macOS screenshot showing the restored rail, hover preview, and active indicator? The written measurements are useful, but a screenshot would make the final visual review much easier.

Please feel free to push back if there is an existing Astryx navigation seam or lifecycle signal that I missed.

AI assistance disclosure: Codex was used to inspect the PR diff and independently challenge the scroll lifecycle and test coverage. Astro-Han reviewed the cited code paths, checked the findings against the current head, calibrated their severity, and made the final review decision. The reported interaction sequences were derived from the code paths and were not independently replayed in the packaged application.

Comment thread packages/ui/src/prompt-anchor-rail.tsx Outdated
Comment thread apps/desktop/e2e/prompt-rail.spec.ts Outdated
ARE404 added 3 commits August 13, 2026 11:21
apache#2580 moved the rail's tick onto Astryx's Button. The bar the tick draws
was a direct child of the flex tick and got blockified; the Button wraps
its children in a label span, so the bar went back to normal flow as an
inline box. An inline box takes no width or height, so every bar computed
to 0x0 and the rail shipped invisible in 0.1.9 and 0.1.10 — present in the
DOM, painting nothing.

`display: block` on the bar restores it. Measured on the new fixture at
1280x800: the rail's box goes from 8px wide (its own padding, ticks
contributing nothing) back to the designed 22px.

This is the third time the rail has failed by rendering and not painting
— apache#2161 pinned it against a containing block as tall as the conversation,
apache#2338 parked it under macOS's overlay scrollbar — and the second time it
reached a release. The e2e coverage that would have caught all three was
deleted in apache#2462, and the multi-prompt fixtures it ran on in apache#2656, so
this adds back the smallest thing that closes the gap:

- `chat-prompt-rail`, a plain 8-prompt conversation. The rail hides itself
  below three prompts, so the shipped single-prompt fixture cannot show it
  at all.
- `prompt-rail.spec.ts` with one test per past failure: bars have a real
  box, the rail stays inside the scrollport at both scroll extremes, and a
  tick is what the pointer lands on. Three tests where the deleted suite
  had nine.

Verified the first test fails on the unfixed renderer with "Expected: > 0,
Received: 0" and passes with the fix. Neither a static CSS read nor a
jsdom unit test can see any of this: jsdom has no layout engine.
Four things the rail got wrong once it was visible again, found by using it:

- A 4px gap between ticks was a band where the pointer was over the rail
  and over no tick, so the dock-style hover falloff dropped out and picked
  up again every few pixels of travel. The rail's `gap` moves into the
  ticks' own `padding-block`: same pitch, hit boxes now tile.
- The hover preview waited 300ms before opening — Astryx's HoverCard
  default, meant for a pointer crossing a wide row on its way somewhere
  else. A tick is 22px of rail that nothing is on the way to, and the wait
  is the one part of this hover with no motion in it. Now 120ms.
- The highlight glided 280ms to wherever a click landed, so crossing
  twenty prompts read as the bar flying off across the rail. A click now
  owns the highlight until its scroll settles: no glide, and the scroll
  no longer walks the highlight through every prompt it passes.
- The first click into a session did nothing until the reader scrolled by
  hand. See below.

That last one is a collision between Astryx's auto-follow lock and the
progressive transcript mount, and neither side is wrong on its own.
`useChatStreamScroll` unlocks on a scroll up, detected by comparing
scrollTop between events — but it ignores any scroll event that arrives
with a changed scrollHeight or offsetHeight, because Chrome fires those
when content resizes and they are not the reader moving. A jump into an
unmounted turn mounts it and the fill that follows changes scrollHeight
for several frames, so the jump's own scroll is invisible to the lock: it
stays on, and `scrollIfLocked` pulls the transcript back to the bottom.
Only a wheel gesture broke it, which takes a separate path in Astryx.

`holdJumpDestination` re-aims at the target on each height change until
the fill stops. The last of those scrolls lands with a stable height,
which is the one the lock finally reads as a scroll up. Measured on the
30-prompt fixture: clicking the first tick went to scrollTop 7042 (the
bottom) and now goes to 24 and holds.

The fixture grows from 8 prompts to 30 because the progressive mount's
initial window is 10 — at 8 the head of the transcript is already mounted
and the jump-into-unmounted-turns path never runs at all.

Coverage note: the e2e case for the first click is an end-to-end check,
not a guard. Whether the lock wins depends on which frame the fill lands
on relative to a smooth scroll still in flight, and it goes green against
the unfixed renderer often enough to be worthless as one. The guard is
the `holdJumpDestination` unit test, which drives the frames itself.
Review of apache#2923 found the jump's ownership bound to a clock rather than to
the navigation, and the e2e case that was supposed to guard it asserting
almost nothing. Both hold.

Jump ownership:

- A second click during a jump only replaced the target; the first click's
  700ms timer still governed, and could clear the second jump mid-flight.
  Each click now carries its own sequence and starts its own hold.
- The fixed window is gone. A hold runs until the progressive mount reports
  the transcript filled AND nothing has moved for a few frames, so a long
  transcript is never released mid-fill, and it ends the moment the reader
  touches the transcript (wheel, touch, pointer, key) rather than outliving
  their interest in it.

Chasing the "just release auto-follow" direction the review preferred found
that ChatLayout publishes no such seam, so this adds one — `unlockAutoFollow`
on `ChatLayoutContextValue`, exposing the scroll hook's existing `unlock`
(patch hunk + patches/README entry). It is necessary and it is not
sufficient, which the earlier framing got wrong:

- Astryx re-locks on any `scrollend` that settles near the bottom, and a
  session that opens at the bottom produces exactly that while the mount is
  still catching up. Releasing once at the click is undone before the jump
  goes anywhere — traced: released at the click, landed at 154ms, dragged
  back to the bottom by 166ms. The release is now re-asserted for the life
  of the hold.
- Auto-follow is not the only thing moving the transcript. The progressive
  mount's own scroll compensation holds the reader's position across each
  fill step, and mounting the turn a jump asked for IS a fill step, so it
  lands after the jump and restores the position the jump just left. That
  one no seam can fix; it is what the hold is for.

Jumps also scroll instantly now, whatever the app's scroll-motion policy
says. A jump is a teleport the reader asked for, and an animated one does
not survive this surface: traced on the 30-prompt fixture, the smooth scroll
was cancelled by the mount's compensation and by the follow spring and
stalled two pixels from where it started.

Coverage:

- The first-click e2e case named the wrong turn (`[data-turn-id]` is the
  first MOUNTED turn, whose top is already negative at the opening scroll
  position, so an upper-bound-only check passed without the jump doing
  anything). It now names `turn-prompt-rail-1`, bounds it on both sides, and
  asserts that tick's `aria-current`.
- `emulateMedia` could not put that case on the production scroll path:
  `resolveScrollMotionBehavior` collapses motion for ANY fixture, keyed on
  `data-maka-e2e-fixture` rather than on the media query. Fixtures can now
  ask for a behavior back (`scrollMotion`, per launch — it costs seconds of
  settling per window, so only the case that needs it pays), with unit
  coverage for the precedence: a fixture request never outranks a stated
  preference for less motion.
- `holdJumpDestination`'s unit tests grew the two cases its rewrite is
  about: it must not settle while the transcript is still filling, and it
  must hand the transcript back the moment the reader touches it.

Verified 5/5 on the smooth-scroll fixture, where the previous revision lost
1 in 4. `quote-selection.spec.ts` flakes on this machine (1 in 4) at
upstream/main as well, unchanged by this branch.
@ARE404
ARE404 force-pushed the are404/fix-prompt-rail-tick-bar branch from 7184e1f to 4c918cf Compare August 13, 2026 08:59
@ARE404

ARE404 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — both P2s hold, and chasing the first one turned up something that changes the framing of this PR. Pushed as 4c918cf on a rebase onto current main.

P2-1 — jump ownership

Fixed as you described, and then some.

  • Per-click generation. Each click carries its own sequence and starts its own hold. A second click no longer inherits the first one's lifetime.
  • The fixed window is gone. A hold now runs until the progressive mount reports the transcript filled and nothing has moved for a few frames, and ends the moment the reader touches the transcript (wheel, touchstart, pointerdown, keydown). A 240-frame budget remains as a backstop for a transcript that never reports itself filled — not as the mechanism.

On your preferred direction: you were right that no seam exists, so this adds one. unlockAutoFollow on ChatLayoutContextValue, exposing the scroll hook's existing unlock — one patch hunk, with a patches/README.md entry, alongside the conversationKey hunk that is already there for the same class of reason.

It is necessary. It is not sufficient, and I could not delete holdJumpDestination as we both hoped. Two findings from tracing it:

  1. One release is not enough. Astryx re-locks on any scrollend that settles near the bottom, and a session that opens at the bottom produces exactly that while the mount is still catching up. Traced: released at the click, landed on the target at 154ms, dragged back to the bottom by 166ms. The release is now re-asserted for the life of the hold.
  2. Auto-follow is not the only thing moving the transcript. The progressive mount's own scroll compensation holds the reader's position across each fill step — and mounting the turn a jump asked for is a fill step, so the compensation lands after the jump's scroll and restores the position it just left. No seam on Astryx's side can address that one; it is ours. That is what the hold is for, and why it stays.

So the hold's job is narrower than before but real: re-aim through the mount, correct once more if a still frame finds the target off the top edge (a scroll cancelled part-way never resumes on its own), then hand back.

One behavior change worth calling out: a jump now scrolls instantly, whatever the scroll-motion policy says. An animated jump does not survive this surface — traced on the 30-prompt fixture, the smooth scroll was cancelled by the compensation and the follow spring and stalled two pixels from where it started. A jump is a teleport the reader asked for; landing reliably beats animating unreliably. Happy to revisit if you would rather keep the animation and accept the correction on top of it.

P2-2 — the e2e case

Every word of this was right, including that my own commit message claimed the opposite. The case now:

  • names turn-prompt-rail-1 instead of [data-turn-id] (which is the first mounted turn — at the opening scroll position its top is already negative, so the upper-bound-only check passed without the jump doing anything),
  • bounds the landing on both sides, and asserts that tick's aria-current,
  • runs on a fixture that actually scrolls smoothly.

That last one needed a seam too: resolveScrollMotionBehavior collapses motion for any fixture, keyed on data-maka-e2e-fixture rather than on the media query, so emulateMedia was inert. Fixtures can now ask for a behavior back (scrollMotion), per launch rather than per scenario — it costs several seconds of settling per window, so only the case that needs it pays, and the other four rail cases keep the deterministic window. Unit coverage for the precedence: a fixture request never outranks a stated preference for less motion.

It went from 1-in-4 failing to 5/5 on repeat runs.

I have kept the honest caveat in the file: this case is an end-to-end path check, and the guard is the holdJumpDestination unit test, which drives its own frames. Two cases were added there for the rewrite — it must not settle while the transcript is still filling, and it must yield to reader input.

Screenshot

Fixture window, macOS, 1280×800 — 30 ticks, the hover preview open on the 7th, and the active indicator on the turn being read:

prompt rail, hover preview, active indicator

Validation

format:check, four-tsconfig typecheck, check-astryx-alignment, core 539 / ui 133 / desktop 778 unit tests, prompt-rail.spec.ts 5/5.

One note so it is not read as this branch's doing: quote-selection.spec.ts flakes on my machine about 1 run in 4, and it does so at upstream/main untouched (verified 4 runs on each). slash-command-menu.spec.ts:87 behaves the same way.

@Astro-Han

Copy link
Copy Markdown
Contributor

LGTM! the E2E problem exist on main so does not block merging.

@Astro-Han
Astro-Han merged commit 4be52a4 into apache:main Aug 13, 2026
9 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants