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

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:

BeforeAfter
Hover falloffDropped out in the 4px gap between ticksHit boxes tile; the pitch is unchanged
Preview card300ms of nothing before it opened (Astryx's default)120ms
Jump highlightGlided 280ms across the rail, then hopped along with the scrollSwitches once, instantly, and holds
First click of a sessionDid nothing until the reader scrolled by handLands 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 statelastScrollTopRef.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-HanAstro-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 threadpackages/ui/src/prompt-anchor-rail.tsx Outdated
Comment threadapps/desktop/e2e/prompt-rail.spec.ts Outdated
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
ARE404force-pushed the are404/fix-prompt-rail-tick-bar branch from 7184e1f to 4c918cfCompareAugust 13, 2026 08:59
@ARE404

Copy link
Copy Markdown
ContributorAuthor

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:mainAug 13, 2026
9 of 10 checks passed
Astro-Han added a commit that referenced this pull request Aug 29, 2026
chat-surface-layout states that Astryx owns scrolling and new-message
following. arrival-bottom-pin was a second implementation of exactly
that, added in #2239 because ChatLayout exposed only scrollContainerRef
and contentRef, so its controller could not be reached. #2923 opened
that seam for unlockAutoFollow and the pin was never revisited.
Reading Astryx's controller, it already covers what six review rounds
put into the pin: resize-synthetic scroll events are excluded by
comparing scrollHeight and offsetHeight, a horizontal wheel is excluded
by requiring deltaY < 0, and gestures are scoped by binding to the
scroller itself rather than by testing where the pointer was. Its
initial fill positions in one frame instead of springing from the top,
which is what the pin's clamp existed to produce.
The one gap was reachability again: on a conversation change the patch
called lock(), which re-enters through the spring because the hook's
initial-fill flag was consumed at mount. Asking for the instant jump
directly closes it, in the patch that was already there.
Removing the pin leaves two moves Astryx cannot see, both now going
through the context: navigating to a turn and loading earlier history
release auto-follow, and "return to latest" resumes it. The second
needed the other half of #2923's seam, so the patch also exposes
scrollToBottom. Both are additive context fields to upstream.
data-turn-window went with the pin it gated: its ready state existed to
release the pin, and the fonts.ready wait plus fifty markdown polls plus
double rAF existed to time that release. The two E2E tests that waited
on it wait for a mounted turn instead, which is what they were after.
latestNavigationNonce was left write-only and goes too.
arrival-bottom-pin.test.ts is replaced by a test of what Maka still
owns, the two release moments, rather than a test of Astryx's internals.
Capability given up: a wheel or touch over the dock while the transcript
is animating now releases following, where the pin discriminated by
gesture origin; and returning to the bottom re-locks following, where
the pin's release was permanent for that arrival.
Generated-by: Claude Code
Astro-Han added a commit to Astro-Han/maka-agent that referenced this pull request Aug 29, 2026
chat-surface-layout states that Astryx owns scrolling and new-message
following. arrival-bottom-pin was a second implementation of exactly
that, added in apache#2239 because ChatLayout exposed only scrollContainerRef
and contentRef, so its controller could not be reached. apache#2923 opened
that seam for unlockAutoFollow and the pin was never revisited.
Reading Astryx's controller, it already covers what six review rounds
put into the pin: resize-synthetic scroll events are excluded by
comparing scrollHeight and offsetHeight, a horizontal wheel is excluded
by requiring deltaY < 0, and gestures are scoped by binding to the
scroller itself rather than by testing where the pointer was. Its
initial fill positions in one frame instead of springing from the top,
which is what the pin's clamp existed to produce.
The one gap was reachability again: on a conversation change the patch
called lock(), which re-enters through the spring because the hook's
initial-fill flag was consumed at mount. Asking for the instant jump
directly closes it, in the patch that was already there.
Removing the pin leaves two moves Astryx cannot see, both now going
through the context: navigating to a turn and loading earlier history
release auto-follow, and "return to latest" resumes it. The second
needed the other half of apache#2923's seam, so the patch also exposes
scrollToBottom. Both are additive context fields to upstream.
data-turn-window went with the pin it gated: its ready state existed to
release the pin, and the fonts.ready wait plus fifty markdown polls plus
double rAF existed to time that release. The two E2E tests that waited
on it wait for a mounted turn instead, which is what they were after.
latestNavigationNonce was left write-only and goes too.
arrival-bottom-pin.test.ts is replaced by a test of what Maka still
owns, the two release moments, rather than a test of Astryx's internals.
Capability given up: a wheel or touch over the dock while the transcript
is animating now releases following, where the pin discriminated by
gesture origin; and returning to the bottom re-locks following, where
the pin's release was permanent for that arrival.
Generated-by: Claude Code
Sign up for freeto 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

@ARE404@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(ui): restore the prompt anchor rail and fix its hover and jump by ARE404 · Pull Request #2923 · apache/maka · GitHub
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

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:

BeforeAfter
Hover falloffDropped out in the 4px gap between ticksHit boxes tile; the pitch is unchanged
Preview card300ms of nothing before it opened (Astryx's default)120ms
Jump highlightGlided 280ms across the rail, then hopped along with the scrollSwitches once, instantly, and holds
First click of a sessionDid nothing until the reader scrolled by handLands 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 statelastScrollTopRef.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-HanAstro-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 threadpackages/ui/src/prompt-anchor-rail.tsx Outdated
Comment threadapps/desktop/e2e/prompt-rail.spec.ts Outdated
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
ARE404force-pushed the are404/fix-prompt-rail-tick-bar branch from 7184e1f to 4c918cfCompareAugust 13, 2026 08:59
@ARE404

Copy link
Copy Markdown
ContributorAuthor

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:mainAug 13, 2026
9 of 10 checks passed
Astro-Han added a commit that referenced this pull request Aug 29, 2026
chat-surface-layout states that Astryx owns scrolling and new-message
following. arrival-bottom-pin was a second implementation of exactly
that, added in #2239 because ChatLayout exposed only scrollContainerRef
and contentRef, so its controller could not be reached. #2923 opened
that seam for unlockAutoFollow and the pin was never revisited.
Reading Astryx's controller, it already covers what six review rounds
put into the pin: resize-synthetic scroll events are excluded by
comparing scrollHeight and offsetHeight, a horizontal wheel is excluded
by requiring deltaY < 0, and gestures are scoped by binding to the
scroller itself rather than by testing where the pointer was. Its
initial fill positions in one frame instead of springing from the top,
which is what the pin's clamp existed to produce.
The one gap was reachability again: on a conversation change the patch
called lock(), which re-enters through the spring because the hook's
initial-fill flag was consumed at mount. Asking for the instant jump
directly closes it, in the patch that was already there.
Removing the pin leaves two moves Astryx cannot see, both now going
through the context: navigating to a turn and loading earlier history
release auto-follow, and "return to latest" resumes it. The second
needed the other half of #2923's seam, so the patch also exposes
scrollToBottom. Both are additive context fields to upstream.
data-turn-window went with the pin it gated: its ready state existed to
release the pin, and the fonts.ready wait plus fifty markdown polls plus
double rAF existed to time that release. The two E2E tests that waited
on it wait for a mounted turn instead, which is what they were after.
latestNavigationNonce was left write-only and goes too.
arrival-bottom-pin.test.ts is replaced by a test of what Maka still
owns, the two release moments, rather than a test of Astryx's internals.
Capability given up: a wheel or touch over the dock while the transcript
is animating now releases following, where the pin discriminated by
gesture origin; and returning to the bottom re-locks following, where
the pin's release was permanent for that arrival.
Generated-by: Claude Code
Astro-Han added a commit to Astro-Han/maka-agent that referenced this pull request Aug 29, 2026
chat-surface-layout states that Astryx owns scrolling and new-message
following. arrival-bottom-pin was a second implementation of exactly
that, added in apache#2239 because ChatLayout exposed only scrollContainerRef
and contentRef, so its controller could not be reached. apache#2923 opened
that seam for unlockAutoFollow and the pin was never revisited.
Reading Astryx's controller, it already covers what six review rounds
put into the pin: resize-synthetic scroll events are excluded by
comparing scrollHeight and offsetHeight, a horizontal wheel is excluded
by requiring deltaY < 0, and gestures are scoped by binding to the
scroller itself rather than by testing where the pointer was. Its
initial fill positions in one frame instead of springing from the top,
which is what the pin's clamp existed to produce.
The one gap was reachability again: on a conversation change the patch
called lock(), which re-enters through the spring because the hook's
initial-fill flag was consumed at mount. Asking for the instant jump
directly closes it, in the patch that was already there.
Removing the pin leaves two moves Astryx cannot see, both now going
through the context: navigating to a turn and loading earlier history
release auto-follow, and "return to latest" resumes it. The second
needed the other half of apache#2923's seam, so the patch also exposes
scrollToBottom. Both are additive context fields to upstream.
data-turn-window went with the pin it gated: its ready state existed to
release the pin, and the fonts.ready wait plus fifty markdown polls plus
double rAF existed to time that release. The two E2E tests that waited
on it wait for a mounted turn instead, which is what they were after.
latestNavigationNonce was left write-only and goes too.
arrival-bottom-pin.test.ts is replaced by a test of what Maka still
owns, the two release moments, rather than a test of Astryx's internals.
Capability given up: a wheel or touch over the dock while the transcript
is animating now releases following, where the pin discriminated by
gesture origin; and returning to the bottom re-locks following, where
the pin's release was permanent for that arrival.
Generated-by: Claude Code
Sign up for freeto 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

@ARE404@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(ui): restore the prompt anchor rail and fix its hover and jump by ARE404 · Pull Request #2923 · apache/maka · GitHub
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

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:

BeforeAfter
Hover falloffDropped out in the 4px gap between ticksHit boxes tile; the pitch is unchanged
Preview card300ms of nothing before it opened (Astryx's default)120ms
Jump highlightGlided 280ms across the rail, then hopped along with the scrollSwitches once, instantly, and holds
First click of a sessionDid nothing until the reader scrolled by handLands 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 statelastScrollTopRef.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-HanAstro-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 threadpackages/ui/src/prompt-anchor-rail.tsx Outdated
Comment threadapps/desktop/e2e/prompt-rail.spec.ts Outdated
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
ARE404force-pushed the are404/fix-prompt-rail-tick-bar branch from 7184e1f to 4c918cfCompareAugust 13, 2026 08:59
@ARE404

Copy link
Copy Markdown
ContributorAuthor

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:mainAug 13, 2026
9 of 10 checks passed
Astro-Han added a commit that referenced this pull request Aug 29, 2026
chat-surface-layout states that Astryx owns scrolling and new-message
following. arrival-bottom-pin was a second implementation of exactly
that, added in #2239 because ChatLayout exposed only scrollContainerRef
and contentRef, so its controller could not be reached. #2923 opened
that seam for unlockAutoFollow and the pin was never revisited.
Reading Astryx's controller, it already covers what six review rounds
put into the pin: resize-synthetic scroll events are excluded by
comparing scrollHeight and offsetHeight, a horizontal wheel is excluded
by requiring deltaY < 0, and gestures are scoped by binding to the
scroller itself rather than by testing where the pointer was. Its
initial fill positions in one frame instead of springing from the top,
which is what the pin's clamp existed to produce.
The one gap was reachability again: on a conversation change the patch
called lock(), which re-enters through the spring because the hook's
initial-fill flag was consumed at mount. Asking for the instant jump
directly closes it, in the patch that was already there.
Removing the pin leaves two moves Astryx cannot see, both now going
through the context: navigating to a turn and loading earlier history
release auto-follow, and "return to latest" resumes it. The second
needed the other half of #2923's seam, so the patch also exposes
scrollToBottom. Both are additive context fields to upstream.
data-turn-window went with the pin it gated: its ready state existed to
release the pin, and the fonts.ready wait plus fifty markdown polls plus
double rAF existed to time that release. The two E2E tests that waited
on it wait for a mounted turn instead, which is what they were after.
latestNavigationNonce was left write-only and goes too.
arrival-bottom-pin.test.ts is replaced by a test of what Maka still
owns, the two release moments, rather than a test of Astryx's internals.
Capability given up: a wheel or touch over the dock while the transcript
is animating now releases following, where the pin discriminated by
gesture origin; and returning to the bottom re-locks following, where
the pin's release was permanent for that arrival.
Generated-by: Claude Code
Astro-Han added a commit to Astro-Han/maka-agent that referenced this pull request Aug 29, 2026
chat-surface-layout states that Astryx owns scrolling and new-message
following. arrival-bottom-pin was a second implementation of exactly
that, added in apache#2239 because ChatLayout exposed only scrollContainerRef
and contentRef, so its controller could not be reached. apache#2923 opened
that seam for unlockAutoFollow and the pin was never revisited.
Reading Astryx's controller, it already covers what six review rounds
put into the pin: resize-synthetic scroll events are excluded by
comparing scrollHeight and offsetHeight, a horizontal wheel is excluded
by requiring deltaY < 0, and gestures are scoped by binding to the
scroller itself rather than by testing where the pointer was. Its
initial fill positions in one frame instead of springing from the top,
which is what the pin's clamp existed to produce.
The one gap was reachability again: on a conversation change the patch
called lock(), which re-enters through the spring because the hook's
initial-fill flag was consumed at mount. Asking for the instant jump
directly closes it, in the patch that was already there.
Removing the pin leaves two moves Astryx cannot see, both now going
through the context: navigating to a turn and loading earlier history
release auto-follow, and "return to latest" resumes it. The second
needed the other half of apache#2923's seam, so the patch also exposes
scrollToBottom. Both are additive context fields to upstream.
data-turn-window went with the pin it gated: its ready state existed to
release the pin, and the fonts.ready wait plus fifty markdown polls plus
double rAF existed to time that release. The two E2E tests that waited
on it wait for a mounted turn instead, which is what they were after.
latestNavigationNonce was left write-only and goes too.
arrival-bottom-pin.test.ts is replaced by a test of what Maka still
owns, the two release moments, rather than a test of Astryx's internals.
Capability given up: a wheel or touch over the dock while the transcript
is animating now releases following, where the pin discriminated by
gesture origin; and returning to the bottom re-locks following, where
the pin's release was permanent for that arrival.
Generated-by: Claude Code
Sign up for freeto 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

@ARE404@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(ui): restore the prompt anchor rail and fix its hover and jump by ARE404 · Pull Request #2923 · apache/maka · GitHub
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

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:

BeforeAfter
Hover falloffDropped out in the 4px gap between ticksHit boxes tile; the pitch is unchanged
Preview card300ms of nothing before it opened (Astryx's default)120ms
Jump highlightGlided 280ms across the rail, then hopped along with the scrollSwitches once, instantly, and holds
First click of a sessionDid nothing until the reader scrolled by handLands 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 statelastScrollTopRef.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-HanAstro-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 threadpackages/ui/src/prompt-anchor-rail.tsx Outdated
Comment threadapps/desktop/e2e/prompt-rail.spec.ts Outdated
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
ARE404force-pushed the are404/fix-prompt-rail-tick-bar branch from 7184e1f to 4c918cfCompareAugust 13, 2026 08:59
@ARE404

Copy link
Copy Markdown
ContributorAuthor

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:mainAug 13, 2026
9 of 10 checks passed
Astro-Han added a commit that referenced this pull request Aug 29, 2026
chat-surface-layout states that Astryx owns scrolling and new-message
following. arrival-bottom-pin was a second implementation of exactly
that, added in #2239 because ChatLayout exposed only scrollContainerRef
and contentRef, so its controller could not be reached. #2923 opened
that seam for unlockAutoFollow and the pin was never revisited.
Reading Astryx's controller, it already covers what six review rounds
put into the pin: resize-synthetic scroll events are excluded by
comparing scrollHeight and offsetHeight, a horizontal wheel is excluded
by requiring deltaY < 0, and gestures are scoped by binding to the
scroller itself rather than by testing where the pointer was. Its
initial fill positions in one frame instead of springing from the top,
which is what the pin's clamp existed to produce.
The one gap was reachability again: on a conversation change the patch
called lock(), which re-enters through the spring because the hook's
initial-fill flag was consumed at mount. Asking for the instant jump
directly closes it, in the patch that was already there.
Removing the pin leaves two moves Astryx cannot see, both now going
through the context: navigating to a turn and loading earlier history
release auto-follow, and "return to latest" resumes it. The second
needed the other half of #2923's seam, so the patch also exposes
scrollToBottom. Both are additive context fields to upstream.
data-turn-window went with the pin it gated: its ready state existed to
release the pin, and the fonts.ready wait plus fifty markdown polls plus
double rAF existed to time that release. The two E2E tests that waited
on it wait for a mounted turn instead, which is what they were after.
latestNavigationNonce was left write-only and goes too.
arrival-bottom-pin.test.ts is replaced by a test of what Maka still
owns, the two release moments, rather than a test of Astryx's internals.
Capability given up: a wheel or touch over the dock while the transcript
is animating now releases following, where the pin discriminated by
gesture origin; and returning to the bottom re-locks following, where
the pin's release was permanent for that arrival.
Generated-by: Claude Code
Astro-Han added a commit to Astro-Han/maka-agent that referenced this pull request Aug 29, 2026
chat-surface-layout states that Astryx owns scrolling and new-message
following. arrival-bottom-pin was a second implementation of exactly
that, added in apache#2239 because ChatLayout exposed only scrollContainerRef
and contentRef, so its controller could not be reached. apache#2923 opened
that seam for unlockAutoFollow and the pin was never revisited.
Reading Astryx's controller, it already covers what six review rounds
put into the pin: resize-synthetic scroll events are excluded by
comparing scrollHeight and offsetHeight, a horizontal wheel is excluded
by requiring deltaY < 0, and gestures are scoped by binding to the
scroller itself rather than by testing where the pointer was. Its
initial fill positions in one frame instead of springing from the top,
which is what the pin's clamp existed to produce.
The one gap was reachability again: on a conversation change the patch
called lock(), which re-enters through the spring because the hook's
initial-fill flag was consumed at mount. Asking for the instant jump
directly closes it, in the patch that was already there.
Removing the pin leaves two moves Astryx cannot see, both now going
through the context: navigating to a turn and loading earlier history
release auto-follow, and "return to latest" resumes it. The second
needed the other half of apache#2923's seam, so the patch also exposes
scrollToBottom. Both are additive context fields to upstream.
data-turn-window went with the pin it gated: its ready state existed to
release the pin, and the fonts.ready wait plus fifty markdown polls plus
double rAF existed to time that release. The two E2E tests that waited
on it wait for a mounted turn instead, which is what they were after.
latestNavigationNonce was left write-only and goes too.
arrival-bottom-pin.test.ts is replaced by a test of what Maka still
owns, the two release moments, rather than a test of Astryx's internals.
Capability given up: a wheel or touch over the dock while the transcript
is animating now releases following, where the pin discriminated by
gesture origin; and returning to the bottom re-locks following, where
the pin's release was permanent for that arrival.
Generated-by: Claude Code
Sign up for freeto 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

@ARE404@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(ui): restore the prompt anchor rail and fix its hover and jump by ARE404 · Pull Request #2923 · apache/maka · GitHub
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

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:

BeforeAfter
Hover falloffDropped out in the 4px gap between ticksHit boxes tile; the pitch is unchanged
Preview card300ms of nothing before it opened (Astryx's default)120ms
Jump highlightGlided 280ms across the rail, then hopped along with the scrollSwitches once, instantly, and holds
First click of a sessionDid nothing until the reader scrolled by handLands 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 statelastScrollTopRef.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-HanAstro-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 threadpackages/ui/src/prompt-anchor-rail.tsx Outdated
Comment threadapps/desktop/e2e/prompt-rail.spec.ts Outdated
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
ARE404force-pushed the are404/fix-prompt-rail-tick-bar branch from 7184e1f to 4c918cfCompareAugust 13, 2026 08:59
@ARE404

Copy link
Copy Markdown
ContributorAuthor

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:mainAug 13, 2026
9 of 10 checks passed
Astro-Han added a commit that referenced this pull request Aug 29, 2026
chat-surface-layout states that Astryx owns scrolling and new-message
following. arrival-bottom-pin was a second implementation of exactly
that, added in #2239 because ChatLayout exposed only scrollContainerRef
and contentRef, so its controller could not be reached. #2923 opened
that seam for unlockAutoFollow and the pin was never revisited.
Reading Astryx's controller, it already covers what six review rounds
put into the pin: resize-synthetic scroll events are excluded by
comparing scrollHeight and offsetHeight, a horizontal wheel is excluded
by requiring deltaY < 0, and gestures are scoped by binding to the
scroller itself rather than by testing where the pointer was. Its
initial fill positions in one frame instead of springing from the top,
which is what the pin's clamp existed to produce.
The one gap was reachability again: on a conversation change the patch
called lock(), which re-enters through the spring because the hook's
initial-fill flag was consumed at mount. Asking for the instant jump
directly closes it, in the patch that was already there.
Removing the pin leaves two moves Astryx cannot see, both now going
through the context: navigating to a turn and loading earlier history
release auto-follow, and "return to latest" resumes it. The second
needed the other half of #2923's seam, so the patch also exposes
scrollToBottom. Both are additive context fields to upstream.
data-turn-window went with the pin it gated: its ready state existed to
release the pin, and the fonts.ready wait plus fifty markdown polls plus
double rAF existed to time that release. The two E2E tests that waited
on it wait for a mounted turn instead, which is what they were after.
latestNavigationNonce was left write-only and goes too.
arrival-bottom-pin.test.ts is replaced by a test of what Maka still
owns, the two release moments, rather than a test of Astryx's internals.
Capability given up: a wheel or touch over the dock while the transcript
is animating now releases following, where the pin discriminated by
gesture origin; and returning to the bottom re-locks following, where
the pin's release was permanent for that arrival.
Generated-by: Claude Code
Astro-Han added a commit to Astro-Han/maka-agent that referenced this pull request Aug 29, 2026
chat-surface-layout states that Astryx owns scrolling and new-message
following. arrival-bottom-pin was a second implementation of exactly
that, added in apache#2239 because ChatLayout exposed only scrollContainerRef
and contentRef, so its controller could not be reached. apache#2923 opened
that seam for unlockAutoFollow and the pin was never revisited.
Reading Astryx's controller, it already covers what six review rounds
put into the pin: resize-synthetic scroll events are excluded by
comparing scrollHeight and offsetHeight, a horizontal wheel is excluded
by requiring deltaY < 0, and gestures are scoped by binding to the
scroller itself rather than by testing where the pointer was. Its
initial fill positions in one frame instead of springing from the top,
which is what the pin's clamp existed to produce.
The one gap was reachability again: on a conversation change the patch
called lock(), which re-enters through the spring because the hook's
initial-fill flag was consumed at mount. Asking for the instant jump
directly closes it, in the patch that was already there.
Removing the pin leaves two moves Astryx cannot see, both now going
through the context: navigating to a turn and loading earlier history
release auto-follow, and "return to latest" resumes it. The second
needed the other half of apache#2923's seam, so the patch also exposes
scrollToBottom. Both are additive context fields to upstream.
data-turn-window went with the pin it gated: its ready state existed to
release the pin, and the fonts.ready wait plus fifty markdown polls plus
double rAF existed to time that release. The two E2E tests that waited
on it wait for a mounted turn instead, which is what they were after.
latestNavigationNonce was left write-only and goes too.
arrival-bottom-pin.test.ts is replaced by a test of what Maka still
owns, the two release moments, rather than a test of Astryx's internals.
Capability given up: a wheel or touch over the dock while the transcript
is animating now releases following, where the pin discriminated by
gesture origin; and returning to the bottom re-locks following, where
the pin's release was permanent for that arrival.
Generated-by: Claude Code
Sign up for freeto 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

@ARE404@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(ui): restore the prompt anchor rail and fix its hover and jump by ARE404 · Pull Request #2923 · apache/maka · GitHub
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

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:

BeforeAfter
Hover falloffDropped out in the 4px gap between ticksHit boxes tile; the pitch is unchanged
Preview card300ms of nothing before it opened (Astryx's default)120ms
Jump highlightGlided 280ms across the rail, then hopped along with the scrollSwitches once, instantly, and holds
First click of a sessionDid nothing until the reader scrolled by handLands 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 statelastScrollTopRef.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-HanAstro-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 threadpackages/ui/src/prompt-anchor-rail.tsx Outdated
Comment threadapps/desktop/e2e/prompt-rail.spec.ts Outdated
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
ARE404force-pushed the are404/fix-prompt-rail-tick-bar branch from 7184e1f to 4c918cfCompareAugust 13, 2026 08:59
@ARE404

Copy link
Copy Markdown
ContributorAuthor

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:mainAug 13, 2026
9 of 10 checks passed
Astro-Han added a commit that referenced this pull request Aug 29, 2026
chat-surface-layout states that Astryx owns scrolling and new-message
following. arrival-bottom-pin was a second implementation of exactly
that, added in #2239 because ChatLayout exposed only scrollContainerRef
and contentRef, so its controller could not be reached. #2923 opened
that seam for unlockAutoFollow and the pin was never revisited.
Reading Astryx's controller, it already covers what six review rounds
put into the pin: resize-synthetic scroll events are excluded by
comparing scrollHeight and offsetHeight, a horizontal wheel is excluded
by requiring deltaY < 0, and gestures are scoped by binding to the
scroller itself rather than by testing where the pointer was. Its
initial fill positions in one frame instead of springing from the top,
which is what the pin's clamp existed to produce.
The one gap was reachability again: on a conversation change the patch
called lock(), which re-enters through the spring because the hook's
initial-fill flag was consumed at mount. Asking for the instant jump
directly closes it, in the patch that was already there.
Removing the pin leaves two moves Astryx cannot see, both now going
through the context: navigating to a turn and loading earlier history
release auto-follow, and "return to latest" resumes it. The second
needed the other half of #2923's seam, so the patch also exposes
scrollToBottom. Both are additive context fields to upstream.
data-turn-window went with the pin it gated: its ready state existed to
release the pin, and the fonts.ready wait plus fifty markdown polls plus
double rAF existed to time that release. The two E2E tests that waited
on it wait for a mounted turn instead, which is what they were after.
latestNavigationNonce was left write-only and goes too.
arrival-bottom-pin.test.ts is replaced by a test of what Maka still
owns, the two release moments, rather than a test of Astryx's internals.
Capability given up: a wheel or touch over the dock while the transcript
is animating now releases following, where the pin discriminated by
gesture origin; and returning to the bottom re-locks following, where
the pin's release was permanent for that arrival.
Generated-by: Claude Code
Astro-Han added a commit to Astro-Han/maka-agent that referenced this pull request Aug 29, 2026
chat-surface-layout states that Astryx owns scrolling and new-message
following. arrival-bottom-pin was a second implementation of exactly
that, added in apache#2239 because ChatLayout exposed only scrollContainerRef
and contentRef, so its controller could not be reached. apache#2923 opened
that seam for unlockAutoFollow and the pin was never revisited.
Reading Astryx's controller, it already covers what six review rounds
put into the pin: resize-synthetic scroll events are excluded by
comparing scrollHeight and offsetHeight, a horizontal wheel is excluded
by requiring deltaY < 0, and gestures are scoped by binding to the
scroller itself rather than by testing where the pointer was. Its
initial fill positions in one frame instead of springing from the top,
which is what the pin's clamp existed to produce.
The one gap was reachability again: on a conversation change the patch
called lock(), which re-enters through the spring because the hook's
initial-fill flag was consumed at mount. Asking for the instant jump
directly closes it, in the patch that was already there.
Removing the pin leaves two moves Astryx cannot see, both now going
through the context: navigating to a turn and loading earlier history
release auto-follow, and "return to latest" resumes it. The second
needed the other half of apache#2923's seam, so the patch also exposes
scrollToBottom. Both are additive context fields to upstream.
data-turn-window went with the pin it gated: its ready state existed to
release the pin, and the fonts.ready wait plus fifty markdown polls plus
double rAF existed to time that release. The two E2E tests that waited
on it wait for a mounted turn instead, which is what they were after.
latestNavigationNonce was left write-only and goes too.
arrival-bottom-pin.test.ts is replaced by a test of what Maka still
owns, the two release moments, rather than a test of Astryx's internals.
Capability given up: a wheel or touch over the dock while the transcript
is animating now releases following, where the pin discriminated by
gesture origin; and returning to the bottom re-locks following, where
the pin's release was permanent for that arrival.
Generated-by: Claude Code
Sign up for freeto 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

@ARE404@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(ui): restore the prompt anchor rail and fix its hover and jump by ARE404 · Pull Request #2923 · apache/maka · GitHub
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

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:

BeforeAfter
Hover falloffDropped out in the 4px gap between ticksHit boxes tile; the pitch is unchanged
Preview card300ms of nothing before it opened (Astryx's default)120ms
Jump highlightGlided 280ms across the rail, then hopped along with the scrollSwitches once, instantly, and holds
First click of a sessionDid nothing until the reader scrolled by handLands 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 statelastScrollTopRef.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-HanAstro-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 threadpackages/ui/src/prompt-anchor-rail.tsx Outdated
Comment threadapps/desktop/e2e/prompt-rail.spec.ts Outdated
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
ARE404force-pushed the are404/fix-prompt-rail-tick-bar branch from 7184e1f to 4c918cfCompareAugust 13, 2026 08:59
@ARE404

Copy link
Copy Markdown
ContributorAuthor

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:mainAug 13, 2026
9 of 10 checks passed
Astro-Han added a commit that referenced this pull request Aug 29, 2026
chat-surface-layout states that Astryx owns scrolling and new-message
following. arrival-bottom-pin was a second implementation of exactly
that, added in #2239 because ChatLayout exposed only scrollContainerRef
and contentRef, so its controller could not be reached. #2923 opened
that seam for unlockAutoFollow and the pin was never revisited.
Reading Astryx's controller, it already covers what six review rounds
put into the pin: resize-synthetic scroll events are excluded by
comparing scrollHeight and offsetHeight, a horizontal wheel is excluded
by requiring deltaY < 0, and gestures are scoped by binding to the
scroller itself rather than by testing where the pointer was. Its
initial fill positions in one frame instead of springing from the top,
which is what the pin's clamp existed to produce.
The one gap was reachability again: on a conversation change the patch
called lock(), which re-enters through the spring because the hook's
initial-fill flag was consumed at mount. Asking for the instant jump
directly closes it, in the patch that was already there.
Removing the pin leaves two moves Astryx cannot see, both now going
through the context: navigating to a turn and loading earlier history
release auto-follow, and "return to latest" resumes it. The second
needed the other half of #2923's seam, so the patch also exposes
scrollToBottom. Both are additive context fields to upstream.
data-turn-window went with the pin it gated: its ready state existed to
release the pin, and the fonts.ready wait plus fifty markdown polls plus
double rAF existed to time that release. The two E2E tests that waited
on it wait for a mounted turn instead, which is what they were after.
latestNavigationNonce was left write-only and goes too.
arrival-bottom-pin.test.ts is replaced by a test of what Maka still
owns, the two release moments, rather than a test of Astryx's internals.
Capability given up: a wheel or touch over the dock while the transcript
is animating now releases following, where the pin discriminated by
gesture origin; and returning to the bottom re-locks following, where
the pin's release was permanent for that arrival.
Generated-by: Claude Code
Astro-Han added a commit to Astro-Han/maka-agent that referenced this pull request Aug 29, 2026
chat-surface-layout states that Astryx owns scrolling and new-message
following. arrival-bottom-pin was a second implementation of exactly
that, added in apache#2239 because ChatLayout exposed only scrollContainerRef
and contentRef, so its controller could not be reached. apache#2923 opened
that seam for unlockAutoFollow and the pin was never revisited.
Reading Astryx's controller, it already covers what six review rounds
put into the pin: resize-synthetic scroll events are excluded by
comparing scrollHeight and offsetHeight, a horizontal wheel is excluded
by requiring deltaY < 0, and gestures are scoped by binding to the
scroller itself rather than by testing where the pointer was. Its
initial fill positions in one frame instead of springing from the top,
which is what the pin's clamp existed to produce.
The one gap was reachability again: on a conversation change the patch
called lock(), which re-enters through the spring because the hook's
initial-fill flag was consumed at mount. Asking for the instant jump
directly closes it, in the patch that was already there.
Removing the pin leaves two moves Astryx cannot see, both now going
through the context: navigating to a turn and loading earlier history
release auto-follow, and "return to latest" resumes it. The second
needed the other half of apache#2923's seam, so the patch also exposes
scrollToBottom. Both are additive context fields to upstream.
data-turn-window went with the pin it gated: its ready state existed to
release the pin, and the fonts.ready wait plus fifty markdown polls plus
double rAF existed to time that release. The two E2E tests that waited
on it wait for a mounted turn instead, which is what they were after.
latestNavigationNonce was left write-only and goes too.
arrival-bottom-pin.test.ts is replaced by a test of what Maka still
owns, the two release moments, rather than a test of Astryx's internals.
Capability given up: a wheel or touch over the dock while the transcript
is animating now releases following, where the pin discriminated by
gesture origin; and returning to the bottom re-locks following, where
the pin's release was permanent for that arrival.
Generated-by: Claude Code
Sign up for freeto 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

@ARE404@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(ui): restore the prompt anchor rail and fix its hover and jump by ARE404 · Pull Request #2923 · apache/maka · GitHub
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

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:

BeforeAfter
Hover falloffDropped out in the 4px gap between ticksHit boxes tile; the pitch is unchanged
Preview card300ms of nothing before it opened (Astryx's default)120ms
Jump highlightGlided 280ms across the rail, then hopped along with the scrollSwitches once, instantly, and holds
First click of a sessionDid nothing until the reader scrolled by handLands 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 statelastScrollTopRef.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-HanAstro-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 threadpackages/ui/src/prompt-anchor-rail.tsx Outdated
Comment threadapps/desktop/e2e/prompt-rail.spec.ts Outdated
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
ARE404force-pushed the are404/fix-prompt-rail-tick-bar branch from 7184e1f to 4c918cfCompareAugust 13, 2026 08:59
@ARE404

Copy link
Copy Markdown
ContributorAuthor

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:mainAug 13, 2026
9 of 10 checks passed
Astro-Han added a commit that referenced this pull request Aug 29, 2026
chat-surface-layout states that Astryx owns scrolling and new-message
following. arrival-bottom-pin was a second implementation of exactly
that, added in #2239 because ChatLayout exposed only scrollContainerRef
and contentRef, so its controller could not be reached. #2923 opened
that seam for unlockAutoFollow and the pin was never revisited.
Reading Astryx's controller, it already covers what six review rounds
put into the pin: resize-synthetic scroll events are excluded by
comparing scrollHeight and offsetHeight, a horizontal wheel is excluded
by requiring deltaY < 0, and gestures are scoped by binding to the
scroller itself rather than by testing where the pointer was. Its
initial fill positions in one frame instead of springing from the top,
which is what the pin's clamp existed to produce.
The one gap was reachability again: on a conversation change the patch
called lock(), which re-enters through the spring because the hook's
initial-fill flag was consumed at mount. Asking for the instant jump
directly closes it, in the patch that was already there.
Removing the pin leaves two moves Astryx cannot see, both now going
through the context: navigating to a turn and loading earlier history
release auto-follow, and "return to latest" resumes it. The second
needed the other half of #2923's seam, so the patch also exposes
scrollToBottom. Both are additive context fields to upstream.
data-turn-window went with the pin it gated: its ready state existed to
release the pin, and the fonts.ready wait plus fifty markdown polls plus
double rAF existed to time that release. The two E2E tests that waited
on it wait for a mounted turn instead, which is what they were after.
latestNavigationNonce was left write-only and goes too.
arrival-bottom-pin.test.ts is replaced by a test of what Maka still
owns, the two release moments, rather than a test of Astryx's internals.
Capability given up: a wheel or touch over the dock while the transcript
is animating now releases following, where the pin discriminated by
gesture origin; and returning to the bottom re-locks following, where
the pin's release was permanent for that arrival.
Generated-by: Claude Code
Astro-Han added a commit to Astro-Han/maka-agent that referenced this pull request Aug 29, 2026
chat-surface-layout states that Astryx owns scrolling and new-message
following. arrival-bottom-pin was a second implementation of exactly
that, added in apache#2239 because ChatLayout exposed only scrollContainerRef
and contentRef, so its controller could not be reached. apache#2923 opened
that seam for unlockAutoFollow and the pin was never revisited.
Reading Astryx's controller, it already covers what six review rounds
put into the pin: resize-synthetic scroll events are excluded by
comparing scrollHeight and offsetHeight, a horizontal wheel is excluded
by requiring deltaY < 0, and gestures are scoped by binding to the
scroller itself rather than by testing where the pointer was. Its
initial fill positions in one frame instead of springing from the top,
which is what the pin's clamp existed to produce.
The one gap was reachability again: on a conversation change the patch
called lock(), which re-enters through the spring because the hook's
initial-fill flag was consumed at mount. Asking for the instant jump
directly closes it, in the patch that was already there.
Removing the pin leaves two moves Astryx cannot see, both now going
through the context: navigating to a turn and loading earlier history
release auto-follow, and "return to latest" resumes it. The second
needed the other half of apache#2923's seam, so the patch also exposes
scrollToBottom. Both are additive context fields to upstream.
data-turn-window went with the pin it gated: its ready state existed to
release the pin, and the fonts.ready wait plus fifty markdown polls plus
double rAF existed to time that release. The two E2E tests that waited
on it wait for a mounted turn instead, which is what they were after.
latestNavigationNonce was left write-only and goes too.
arrival-bottom-pin.test.ts is replaced by a test of what Maka still
owns, the two release moments, rather than a test of Astryx's internals.
Capability given up: a wheel or touch over the dock while the transcript
is animating now releases following, where the pin discriminated by
gesture origin; and returning to the bottom re-locks following, where
the pin's release was permanent for that arrival.
Generated-by: Claude Code
Sign up for freeto 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

@ARE404@Astro-Han