feat(ui): prompt anchor rail — jump between prompts in a conversation - #563
Merged
Merged
Conversation
A slim right-edge rail in the chat view with one tick per user prompt — Codex / ChatGPT-style navigation for long conversations. Click a tick to scroll that prompt into view; the tick for the turn at the top of the viewport stays highlighted; hovering a tick previews the prompt plus the start of its reply. Reuses the chat view's existing `[data-turn-id]` anchors and the scroll-into-view path; an IntersectionObserver tracks the active turn. The tick bar draws in `currentColor` and active/hover just shift the neutral text color (muted -> primary), so there's no brand rail or hover-surface background. Hidden until there are 3+ prompts. Ticks are keyboard-focusable buttons with aria-labels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7 tasks
ARE404
added a commit
to ARE404/maka-agent
that referenced
this pull request
Aug 4, 2026
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 `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 scrollport's lower band belongs to the sticky composer dock, though, so centring on the bare scrollport ran the lower ticks under it — 122px of overlap at 1240x617, over the frosted blur and the composer card. The rail centres on, and is capped to, what is left above the dock. 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. 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; - it clears the dock at 860/700/617/500px window heights, and 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 about 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 — a new `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; - all of it holds with the sidebar and the session workbar open. The new fixture is deliberately its own seed rather than a short tail on `long-transcript`: that fixture's warm-up contract reads every turn as taller than its 250px content-visibility placeholder, and a turn that shrinks on warm-up instead of growing would change what it measures.
ARE404
added a commit
to ARE404/maka-agent
that referenced
this pull request
Aug 4, 2026
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 `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 scrollport's lower band belongs to the sticky composer dock, though, so centring on the bare scrollport ran the lower ticks under it — 122px of overlap at 1240x617, over the frosted blur and the composer card. The rail centres on, and is capped to, what is left above the dock. 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. 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; - it clears the dock at 860/700/617/500px window heights, and 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 about 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 — a new `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; - all of it holds with the sidebar and the session workbar open. The new fixture is deliberately its own seed rather than a short tail on `long-transcript`: that fixture's warm-up contract reads every turn as taller than its 250px content-visibility placeholder, and a turn that shrinks on warm-up instead of growing would change what it measures.
ARE404
added a commit
to ARE404/maka-agent
that referenced
this pull request
Aug 5, 2026
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.
Astro-Han
pushed a commit
that referenced
this pull request
Aug 5, 2026
) The prompt navigation rail (#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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A slim prompt navigation rail on the right edge of the chat view — one tick per user prompt, so you can jump between your questions in a long conversation (Codex / ChatGPT style).
IntersectionObserver).How
Reuses infrastructure the chat view already has, so the surface area is small:
[data-turn-id], and there's already a scroll-into-view path — a tick click just callsscrollIntoViewon the anchor.PromptAnchorRail(packages/ui/src/prompt-anchor-rail.tsx) owns the ticks + anIntersectionObserverfor the active turn.chat-view.tsxrenders it inside.maka-chat-shellwith a memoized{turnId, label, reply}list derived from the existingturns.Design / governance notes
currentColor; active/hover only shift the neutral text color (--muted-foreground→--foreground) — no brand rail, no hover-surface background (keeps it within the#499state-token +#406accent-ban governance).--space-*/--opacity-*/--z-*/ semantic-color tokens per the spacing / foreground-tier / opacity / z-index / focus-ring / cursor contracts.<button>s witharia-label(jump to prompt: …) andaria-currenton the active one.Testing
npm run build, desktoptypecheck, the full desktop test suite (incl. the CSS-governance + a11y contracts above), andcheck-dead-cssall pass. Verified live in the running app: rail renders one tick per prompt, active tracking follows scroll, click jumps, and the hover preview shows prompt + reply.🤖 Generated with Claude Code