fix(ui): restore the prompt anchor rail and fix its hover and jump - #2923
Conversation
Astro-Han
left a comment
There was a problem hiding this comment.
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.
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.
7184e1f to
4c918cf
Compare
|
Thanks — both P2s hold, and chasing the first one turned up something that changes the framing of this PR. Pushed as P2-1 — jump ownershipFixed as you described, and then some.
On your preferred direction: you were right that no seam exists, so this adds one. It is necessary. It is not sufficient, and I could not delete
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 caseEvery word of this was right, including that my own commit message claimed the opposite. The case now:
That last one needed a seam too: 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 ScreenshotFixture window, macOS, 1280×800 — 30 ticks, the hover preview open on the 7th, and the active indicator on the turn being read: Validation
One note so it is not read as this branch's doing: |
|
LGTM! the E2E problem exist on main so does not block merging. |

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: blockon the bar.Using it once it was back turned up four more things, all fixed here:
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.
useChatStreamScrollkeeps the transcript pinned to the bottom while a turn streams, and unlocks when the reader scrolls up — detected by comparingscrollTopacross 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 changedscrollHeightoroffsetHeight: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
scrollHeightfor several frames. The jump's own scroll therefore arrives with a changed height every time and is discarded: the lock stays on,scrollIfLockedpulls the transcript back to the bottom, and the click reads as dead. A wheel gesture broke it becauseonWheeltakes a separate path — which is exactly why scrolling by hand "fixed" it.holdJumpDestinationre-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 atscrollTop7042 (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:
chat-prompt-rail, a plain 30-prompt conversation. The shipped fixture has one prompt and the rail hides itself below three, so nothing could show it at all. 30 rather than 8 because the progressive mount's initial window is 10 — below that the head of the transcript is already mounted and the jump-into-unmounted-turns path never runs.prompt-rail.spec.ts: bars have a real box (feat(ui): align high-traffic chrome with Astryx primitives #2580), the rail stays inside the scrollport at both scroll extremes (fix(ui): pin the prompt anchor rail to the Astryx chat scrollport #2161 — the bottom is where sticky clamping bites), the pointer is always on a tick as it travels down the rail, the first click lands and holds, and a tick is what the pointer lands on rather than the scrollbar (fix(ui): keep the prompt rail clickable on macOS #2338).holdJumpDestinationdriving its own frames.Two honest limits:
data-maka-e2e-fixture, andbase.cssgives thatanimation: noneplus 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.tsis 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.mjsslash-command-menuflake that passes on re-run and is unrelated to this change;prompt-rail.spec.ts5/5Expected: > 0, Received: 0, and the gapless-travel test withExpected: 0, Received: 28