Skip to content

fix(ui): pin the prompt anchor rail to the Astryx chat scrollport - #2161

Merged
Astro-Han merged 1 commit into
apache:mainfrom
ARE404:are404/fix-prompt-rail-astryx
Aug 5, 2026
Merged

fix(ui): pin the prompt anchor rail to the Astryx chat scrollport#2161
Astro-Han merged 1 commit into
apache:mainfrom
ARE404:are404/fix-prompt-rail-astryx

Conversation

@ARE404

@ARE404 ARE404 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What

The prompt navigation rail from #563 has been invisible since Astryx's ChatLayout took over the chat surface. Nothing deleted it — it is laid out somewhere nobody can see.

The rail pins itself with position: absolute, which resolved against .maka-chat-shell back when the chat view owned a viewport-sized scroll box. ChatLayout is the scroll container now and the whole transcript — chat shell included — renders inside it, so that containing block became as tall as the conversation. Measured against the 24-turn longTranscriptWindow fixture:

before after
rail height 32544px (in a 780px scrollport) 268px
distance from scrollport top, scrolled to the bottom -32048px 0 (centred, no drift)

So it centred itself in the middle of the document and scrolled away with the content.

How

Pin it with a zero-height sticky anchor — the same mechanism Astryx uses for the composer dock. It costs no flow space, and top: 50% parks it on the scrollport's centreline at every scroll position, so the rail never has to learn the scroll container's height. Sticky only holds an offset from its own static position onward, so ChatView renders the rail as the first child of the shell rather than after the transcript.

The hover preview moves onto Astryx's HoverCard. It is portalled, so the topmost and bottommost ticks no longer push their preview off the window edge, and the card brings the theme's own surface, radius, and shadow instead of ~30 lines of hand-positioned popover CSS. (Tooltip portals too, but its palette is deliberately inverted for short strings; this preview is a prompt plus a slice of its reply and should read as a card.)

And, while it was a static row of dashes, some motion:

  • The highlight for the prompt being read is one bar that travels, anchored to the active tick with CSS anchor positioning — Astryx's own idiom for a sliding indicator, see its Outline. The anchor sits on the tick's inner bar, not the tick: HoverCard sets its own anchor-name on the trigger it wraps, inline, and that wins over any rule of ours. (A pseudo-element can never be an anchor, so the bar became a real element.)
  • Hovering a tick swells it and its neighbours with a three-tick falloff, which reads as the rail leaning toward the pointer. Sibling selectors cannot express it — each tick is wrapped in its own display: contents element — so the rail tracks the hovered index and hands each tick its distance as a custom property.
  • The rail rests tucked toward the window edge and settles inward under the pointer, and ticks slide in staggered rather than appearing: a new prompt's tick arrives, and a whole transcript cascades on mount.

Every one of those is a transition or animation on the existing --duration-* / --ease-* tokens, so the renderer's global reduced-motion rule already collapses them — no separate opt-out.

Testing

New apps/desktop/e2e/prompt-rail.spec.ts, on the real surface against the long-transcript fixture:

  • the rail's box stays inside the scrollport, and shorter than it, at the top, middle, and bottom of a 24-turn conversation;
  • a tick click brings its prompt to rest at the top of the scrollport and marks it aria-current;
  • the highlight tracks the active tick's centreline and right edge to within 1px, and actually changes tick across three scroll positions;
  • the hover falloff steps down on both sides of the pointer and settles back to a uniform rail on leave.

The last two are live-window assertions on purpose: anchor positioning fails silently — an unresolved anchor still renders, just parked at a fixed offset — which is exactly how the first cut of the sliding highlight broke, and a stylesheet read cannot tell the two apart.

npm run build, npm run typecheck, npm run format:check, check-dead-css, check-a11y, check-console, check-copy, the desktop unit suite, and the desktop e2e suite all pass on upstream/main. The one e2e failure locally is providers.spec.ts "adds a catalog provider", where a 网络错误 toast intercepts a click; it fails identically on an untouched base here.

🤖 Generated with Claude Code

@Astro-Han

Astro-Han commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Thanks for bringing the prompt rail back — the sticky-anchor direction makes sense, and the new E2E coverage is a useful improvement.

I found two bottom-of-transcript cases that I think are worth addressing:

  1. At common desktop widths (1240/1100/1000px), the last rail ticks overlap the sticky composer dock hit layer. The ticks are visually present, but hit-testing at the last tick resolves to .maka-chat-layout, so the bottom prompts cannot be clicked.
  2. With a short final turn, scrolling fully to the bottom can leave the previous prompt active. The observer only considers the top 34% of the viewport, and the final turn may never reach that region, so aria-current and the indicator remain on the second-to-last tick.

Could we make the rail respect the composer dock actual safe area, and explicitly select the final prompt when the scroller reaches the bottom?

It would also be helpful to extend the E2E test with:

  • a real hit-test/click on the last tick at the bottom, rather than dispatchEvent;
  • a short final-turn fixture asserting that the last tick becomes aria-current;
  • one representative layout with the sidebar/workbar open.

One scope suggestion: the sticky positioning fix is the essential part here, while the hover falloff, staggered entrance, and travelling indicator add quite a bit of complexity. I would lean toward keeping this fix minimal and moving those enhancements to a follow-up unless they are required for this PR.

Overall, the approach is promising; I think these changes would make the behavior robust and keep the fix easier to review and maintain.

@ARE404
ARE404 force-pushed the are404/fix-prompt-rail-astryx branch from 65cfb4c to 0924dd6 Compare August 4, 2026 16:56
@ARE404

ARE404 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — both cases were real. Pushed a rework; details on each, including one place where what I measured differs from the report.

1. The rail runs into the composer dock

Fixed: the rail now centres on, and is capped to, the scrollport minus the dock, measured with a ResizeObserver on both. Neither number is knowable in CSS and neither is constant — the dock grows with the composer's draft and with the panels docked above it — so they are published as custom properties the stylesheet consumes.

One correction on the trigger, in case it matters for how you reproduce it: window height is the variable, not width. The dock's height is fixed by its content while the scrollport shrinks around it, so the band runs out from the bottom. At 1240px wide:

window height rail bottom dock top overlap
860 580 580 0
700 500 420 80px
617 459 337 122px
500 330 220 110px

Equivalently it also triggers on prompt count: at an 860px window the rail outgrows the band past ~28 prompts.

I could not reproduce the unclickable half at 1240/1100/1000 × 860 — document.elementFromPoint at every tick's centre resolved to the tick, at all three widths. The rail's --z-panel-action sits above the dock container's z-index: 0, so it wins hit-testing even while overlapping. That does not make the overlap acceptable — ticks sitting on the frosted blur and the composer card are wrong regardless — but if you were seeing a genuinely dead tick, it may be a case I still haven't got: a taller dock (long draft, or an expanded Agent Graph / Plan panel) would be my first guess, and I'd like the repro if you have it.

Either way, the rail clears the composer dock at every window height now asserts positive clearance and that every tick visible in the rail is the topmost element at its own centre, at all four heights above.

2. Short final turn never becomes current

Fixed, the way Astryx's own useScrollSpy does it: resolve the end of the scroller explicitly, ahead of the activation band. Confirmed the new test goes red without it (aria-current empty on the last tick, reader at the bottom).

The activation band alone genuinely cannot cover this — there is no scroll left to bring a one-line last turn into the top third — so it needed a fixture that ends short. long-transcript can't be that fixture: its warm-up contract in scroll-geometry.spec.ts depends on every turn being taller than its 250px content-visibility placeholder, and a turn that shrinks on warm-up would change what it measures. So short-final-turn is its own seed — five tall turns and a one-line last one.

3. Test coverage

  • Real hit-test on the last tick at the bottom — added, as elementFromPoint at each tick's centre. Note it is the hit test rather than a real .click(): synthesised mouse input doesn't land on these fixture windows (this suite's other specs dispatchEvent for the same reason), and I confirmed a real hover() doesn't reach the renderer either. elementFromPoint is what actually answers "is anything covering this tick", which is the part the dispatch can't see.
  • Short final-turn fixtureshortFinalTurnWindow, asserting aria-current lands on the last tick, alongside a check that the final turn really is below the activation band so the test can't pass for the wrong reason.
  • Sidebar + workbar open — added as its own case over the full probe.

4. Scope

Agreed — the hover falloff, staggered entrance, and travelling indicator are out of this PR. It is now the sticky-anchor fix, the dock safe area, the end-of-scroller rule, and their tests. I've kept the motion on a branch and will raise it separately once this lands, so it can be judged on its own.

The HoverCard swap stayed, since it isn't decoration: the hand-positioned popover clipped at the window edge for the topmost and bottommost ticks, and portalling it is the fix.

Local: build, typecheck, format:check, check-dead-css, check-a11y, check-console, check-copy, 1644 desktop unit tests, 776 core, 321 ui, and the desktop e2e suite (70 passed). The one local e2e failure is providers.spec.ts "adds a catalog provider", where a 网络错误 toast intercepts a click for want of network; it fails identically on an untouched base here and passes in CI.

@ARE404
ARE404 force-pushed the are404/fix-prompt-rail-astryx branch from 0924dd6 to 5906d3a Compare August 4, 2026 17:08
@ARE404

ARE404 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

CI caught a real defect in the previous push that my local run masked, so: fixed, root-caused, and locked with a deterministic assertion.

What CI saw (e2e_shard (1/2), 500px window): insetTop: -62 — 62px of the rail scrolled off the top of the scrollport.

Why. A sticky offset is clamped by its containing block, and the anchor's is .maka-chat-shell, which ends where the transcript does. The dock's box follows the transcript in flow, so at the end of a conversation the shell's bottom sits a dock-height above the scrollport's bottom edge. An anchor parked on the centreline gets dragged upward there. Measured at a 500px window:

scroll shell bottom, from scrollport top
top 32608
bottom 160

The anchor wanted 230 (50% of 460), got clamped to 160, and the rail's own -50% - dock/2 took it to 160 - 222 = -62. CI's number exactly.

Fix. The anchor holds top: 0 — the clamp cannot engage while any transcript is on screen — and the rail does its own centring from the measured band: top: calc((scrollport - dock) / 2). An absolute position from the scrollport's top edge rather than an offset from a centreline that clamping can move.

Lock. the rail clears the composer dock at every window height now runs at both ends of the scroll and asserts the outcome — the rail's centre equals the centre of the band above the dock — rather than the mechanism. Confirmed red against the previous CSS, locally and deterministically, at 617px/bottom: railCentre 139 vs bandCentre 151, with the anchor reading 277 instead of 288 (the clamp). The bottom of the scroll is the load-bearing case and the old test never visited it, which is precisely why this reached CI.

Worth saying plainly: the earlier version passed locally at all four heights, so the previous "verified" claim was weaker than it read. The invariant is now checked at both scroll extremes at every height.

Local after the fix: typecheck, format:check, check-dead-css clean; desktop e2e 70 passed, with the same lone providers.spec.ts network failure that fails on an untouched base here.

@Astro-Han

Copy link
Copy Markdown
Contributor

Thanks for the thorough rework and the detailed measurements. The original dock-overlap and short-final-turn cases look addressed, and removing the decorative motion made the change much easier to reason about.

A fresh-eye pass found one remaining long-transcript edge case: once the rail exceeds its max height, it becomes an independent overflow-y: auto scroller, but changing activeTurnId does not bring the active tick into that rail viewport. In a 100-turn reproduction, scrolling the main transcript to the bottom correctly marks the last tick aria-current, while the rail remains at scrollTop = 0; the active tick is therefore below the visible rail and cannot be seen or reached directly.

The current unreachableTicks helper intentionally skips ticks outside the rail box, so the existing tests can still pass in this state.

Could we keep the active tick visible inside the rail, for example with nearest-block scrolling when the active turn changes, and add an overflowing-rail fixture that asserts the final active tick is fully visible and hit-testable at the bottom?

Everything else in this revision looks much stronger. Thanks again for responding so carefully to the first review.

The prompt navigation rail (apache#563) has been invisible since Astryx's
ChatLayout took over the chat surface. The rail pins itself with
`position: absolute`, which resolved against `.maka-chat-shell` back when
the chat view owned a viewport-sized scroll box. ChatLayout is the scroll
container now and the whole transcript — chat shell included — renders
inside it, so that containing block became as tall as the conversation:
against the 24-turn e2e fixture the rail laid out at 32544px in a 780px
scrollport, centred itself in the middle of the document, and scrolled
away with the content.

Pin it with a zero-height sticky anchor instead — the same mechanism
Astryx uses for the composer dock. It costs no flow space and holds the
scrollport's top edge at every scroll position, so the rail never has to
learn the scroll container's height. Sticky only holds an offset from its
own static position onward, so ChatView renders the rail as the first
child of the shell rather than after the transcript.

The anchor holds the top edge rather than the centreline because a sticky
offset is clamped by its containing block, and this one's is the chat
shell, which ends where the transcript does — above the scrollport's
bottom edge, since the dock's own box follows it in flow. An anchor parked
mid-scrollport gets dragged upward as the reader reaches the end of a
conversation, taking the rail off the top of the scrollport with it.

The scrollport's lower band belongs to the sticky composer dock, so the
rail centres on, and is capped to, what is left above it. Centring on the
bare scrollport ran the lower ticks under the dock — 122px of overlap at
1240x617, over the frosted blur and the composer card. Neither the dock
height nor the scrollport height is knowable in CSS and neither is a
constant — the dock grows with the composer's draft and with the panels
docked above it — so a ResizeObserver on both publishes them as custom
properties.

Past its cap the rail becomes a scroller in its own right, and then
marking a tick active is not enough: the tick can sit outside the rail's
own viewport, where it is neither visible nor clickable. The rail scrolls
the active tick back into view, by arithmetic on itself rather than
`scrollIntoView` — that walks every scrollable ancestor, and the nearest
one here is the transcript the reader is scrolling.

The rail also marks the prompt being read, and a turn only becomes
current once it reaches the top third of the scrollport. A conversation
that ends on a one-line answer has no scroll left to bring its last turn
up there, so the reader could sit at the very bottom with `aria-current`
stranded on the previous prompt. Resolve the end of the scroller
explicitly, the way Astryx's own scroll-spy does.

The hover preview moves onto Astryx's HoverCard. It is portalled, so the
topmost and bottommost ticks no longer push their preview off the window
edge, and the card comes with the theme's own surface, radius, and shadow
instead of ~30 lines of hand-positioned popover CSS. (Tooltip portals too,
but its palette is deliberately inverted for short strings; this preview
is a prompt plus a slice of its reply and should read as a card.)

Verified on the real surface — `prompt-rail.spec.ts`:

- the rail's box stays inside the scrollport, and shorter than it, at the
  top, middle, and bottom of a 24-turn conversation;
- its centre sits on the centre of the band above the dock at 860/700/
  617/500px window heights, at both ends of the scroll — the bottom being
  where sticky clamping bites;
- every tick visible in the rail is the topmost element at its own centre,
  so the claim is about what a pointer would hit rather than paint order;
- a tick click brings its prompt to rest at the top of the scrollport;
- the last prompt becomes current at the end of a transcript whose final
  turn is one line (`short-final-turn` fixture), asserted alongside a
  check that the turn really never reaches the activation band, so the
  test cannot pass for the wrong reason;
- the active tick stays inside the rail's own viewport and hit-testable
  once the rail overflows (`overflowing-rail` fixture, 90 prompts), at
  both ends of the transcript;
- all of it holds with the sidebar and the session workbar open.

Both new fixtures are their own seeds rather than variations on
`long-transcript`: that fixture's warm-up contract reads every turn as
taller than its 250px content-visibility placeholder, and neither a turn
that shrinks on warm-up nor 90 turns of filler would leave it measuring
what it measures today.
@ARE404
ARE404 force-pushed the are404/fix-prompt-rail-astryx branch from 5906d3a to f788ba5 Compare August 5, 2026 02:34
@ARE404

ARE404 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Good catch, and thank you for naming the reason my tests couldn't see it — unreachableTicks skipping out-of-box ticks is exactly the blind spot, and I'd built it deliberately for the case where the rail is capped, without noticing it also exempted the one tick that must never be out of the box.

Fixed. When the active turn changes, the rail brings the active tick back into its own viewport. Done as arithmetic on the rail rather than scrollIntoView({ block: 'nearest' }): scrollIntoView walks every scrollable ancestor, and the nearest one here is the transcript itself — nudging the rail must never move the conversation the reader is scrolling.

const railBox = rail.getBoundingClientRect();
const tickBox = tick.getBoundingClientRect();
if (tickBox.top < railBox.top) rail.scrollTop -= railBox.top - tickBox.top;
else if (tickBox.bottom > railBox.bottom) rail.scrollTop += tickBox.bottom - railBox.bottom;

Fixture + test. New overflowing-rail scenario: 90 short turns — the count is the point, not the height, so the answers stay short rather than multiplying the transcript by 90 for nothing. Confirmed the test goes red without the fix, reproducing your description exactly:

{"found":true,"fullyInsideRail":false,"hit":null,"hitsActiveTick":false,
 "railScrollTop":0,"railScrollHeight":994,"railClientHeight":606}

aria-current on the last tick, rail at scrollTop 0, the tick outside the rail's box and hitting nothing. With the fix it is fully inside, hit-tests to itself, and the rail has scrolled to get there. The test also checks the reverse — back to the top of the transcript, the first tick comes back into the rail's viewport — and asserts up front that the rail actually overflows, so it can't quietly become vacuous.

90 rather than the ~50 that would just barely overflow: at 60 the rail overflowed by only 58px here, close enough to the cap that another platform's dock geometry could erase the premise. At 90 it's 388px.

On the helper. I kept the out-of-box exemption — a tick genuinely scrolled out of a capped rail isn't on screen, and failing on it would be wrong — but its docstring now names the blind spot and points at activeTickVisibility, which is what covers the active tick specifically. Two different contracts: a tick you can see is a tick you can hit, and the active tick is always one you can see.

Local: typecheck, format:check, check-dead-css, check-a11y clean; 1644 desktop unit tests, 776 core; desktop e2e 72 passed, 0 failed (the providers.spec.ts network flake I mentioned earlier had network this time and passed too).

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved. The reported dock-overlap, short-final-turn, and overflowing-rail failures are fixed and covered by deterministic E2E tests. The latest CI is green.

Non-blocking P3: if an already-overflowing rail is resized, or the composer dock grows, while activeTurnId remains unchanged, the active-tick visibility effect does not rerun because it currently depends only on activeTurnId. This requires a rare combination of a very long conversation and a later geometry change, is manually recoverable by scrolling the rail, and does not affect conversation data or the main chat flow. Fine to defer.

@Astro-Han
Astro-Han merged commit ec1de46 into apache:main Aug 5, 2026
12 checks passed
Astro-Han pushed a commit that referenced this pull request Aug 5, 2026
Follow-up to #2161, which deliberately shipped the rail as a static row
of dashes so the positioning fix could be reviewed on its own.

- The highlight for the prompt being read is now one bar that travels,
  anchored to the active tick with CSS anchor positioning — Astryx's own
  idiom for a sliding indicator, see its Outline. Changing the active
  prompt moves something the eye can follow instead of swapping two
  static ticks, which is the part that carries meaning rather than
  decoration: it shows the reader which way through the conversation
  they just moved, and how far.

  The anchor sits on the tick's inner bar, not on the tick: HoverCard
  sets its own `anchor-name` on the trigger it wraps, inline, and that
  wins over any rule of ours. A pseudo-element cannot be an anchor, so
  the bar becomes a real element.

- Hovering a tick swells it and its neighbours over three ticks, which
  reads as the rail leaning toward the pointer and, more practically,
  makes a 3px target easier to aim at without permanently spending more
  width on it. Sibling selectors cannot express the falloff — each tick
  is wrapped in its own `display: contents` element — so the rail tracks
  the hovered index and hands each tick its distance as a custom
  property.

- The rail rests tucked toward the window edge and settles inward under
  the pointer, and ticks slide in staggered rather than appearing, so a
  new prompt's tick arrives and a whole transcript cascades on mount.
  The stagger is capped at 20 ticks: a 90-prompt conversation should
  cascade in a third of a second, not nearly two.

Every one of these is a CSS transition or animation on the existing
duration and easing tokens, which is what puts them under the renderer's
global `prefers-reduced-motion` rule rather than needing an opt-out of
their own.

Testing, and its boundary: e2e-fixture renders carry
`data-maka-e2e-fixture`, which `base.css` gives `animation: none` and a
0.01ms transition cap so a fixture's rendered state never depends on the
millisecond it settles. The new specs therefore assert the end states the
motion resolves to — that the highlight lands on the active tick's
centreline and right edge at three scroll positions and actually changes
tick, that it keeps up when the rail scrolls itself past its cap, and
that the hover falloff steps down on both sides and settles back — and
cannot assert that anything animated on the way there. The tick entrance
has no end state to check and so has no automated coverage; it was
verified by hand in the running app.

The sliding highlight is the one that fails silently — an unresolved
anchor still renders the bar, just parked — which is why it is asserted
in a live window rather than read off the stylesheet.
ARE404 added a commit to ARE404/maka-agent that referenced this pull request Aug 13, 2026
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.
Astro-Han pushed a commit that referenced this pull request Aug 13, 2026
…2923)

* fix(ui): give the prompt rail's tick bars a box again

#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
— #2161 pinned it against a containing block as tall as the conversation,
#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 #2462, and the multi-prompt fixtures it ran on in #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.

* fix(ui): make the prompt rail's hover and jump behave

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.

* fix(ui): own a rail jump through the mount instead of racing it

Review of #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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants