fix(ui): pin the prompt anchor rail to the Astryx chat scrollport - #2161
Conversation
|
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:
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:
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. |
65cfb4c to
0924dd6
Compare
|
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 dockFixed: the rail now centres on, and is capped to, the scrollport minus the dock, measured with a 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:
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 — Either way, 2. Short final turn never becomes currentFixed, the way Astryx's own 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. 3. Test coverage
4. ScopeAgreed — 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: |
0924dd6 to
5906d3a
Compare
|
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 ( Why. A sticky offset is clamped by its containing block, and the anchor's is
The anchor wanted 230 (50% of 460), got clamped to 160, and the rail's own Fix. The anchor holds Lock. 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: |
|
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 The current 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.
5906d3a to
f788ba5
Compare
|
Good catch, and thank you for naming the reason my tests couldn't see it — 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 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
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 Local: |
Astro-Han
left a comment
There was a problem hiding this comment.
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.
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.
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.
…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.
What
The prompt navigation rail from #563 has been invisible since Astryx's
ChatLayouttook 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-shellback when the chat view owned a viewport-sized scroll box.ChatLayoutis 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-turnlongTranscriptWindowfixture: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, soChatViewrenders 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. (Tooltipportals 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:
Outline. The anchor sits on the tick's inner bar, not the tick:HoverCardsets its ownanchor-nameon 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.)display: contentselement — so the rail tracks the hovered index and hands each tick its distance as a custom property.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:aria-current;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 onupstream/main. The one e2e failure locally isproviders.spec.ts"adds a catalog provider", where a网络错误toast intercepts a click; it fails identically on an untouched base here.🤖 Generated with Claude Code