fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll - #269
Draft
jeonghun-jj-lee wants to merge 27 commits into
Draft
fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll#269jeonghun-jj-lee wants to merge 27 commits into
jeonghun-jj-lee wants to merge 27 commits into
Conversation
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
jack-champagne
force-pushed
the
local/amicode
branch
from
August 28, 2026 20:19
88d63c9 to
3779ab8
Compare
A short label (<40 chars ending with ':') no longer splits from its content — the blank line after it is not treated as a chunk boundary. This prevents orphaned single-line cards like 'Results:' appearing alone above their list. The whitespace-only chunk guard was already in splitSettledChunks; tests now explicitly verify it. Closes #632
…l, deterministic offsets Hoist the running HarmonicDot from per-row inline rendering into a single persistent overlay element inside the virtual content container. The dot never remounts while a turn is running — one DOM element throughout. Key changes: - TurnOverlay in message-timeline.tsx: positioned absolutely in virtual space, top computed from virtualizer measurements of the last running row. Updates only on step boundaries (row count changes for the active turn), not per prose-fragment chunk. - dotCentreForGroup(): deterministic offsets (prose=21, tool_group=11, single_tool=16, thinking=11) replace the old ResizeObserver + TreeWalker measurement in TimelineRowFrame. - ThoughtRail renders null for the running dot path; done-dots remain per-row with deterministic centres. - CSS transitions updated to 250ms spring cubic-bezier(0.34, 1.56, 0.64, 1) with slight overshoot. prefers-reduced-motion disables the spring. Closes #630
… teleport New content arriving while anchored to bottom now animates with a custom 180ms ease-out cubic RAF loop instead of instant scrollToEnd(). Cancel on user scroll gesture (wheel, touchstart, pointerdown). prefers-reduced-motion disables smooth scroll (instant fallback). The smoothScrollToEnd helper is used for: - maybeAnchorBottom (new rows append while following) - anchorResizedBottom (row grows while following) - Jump to latest button Instant scroll preserved for: initial mount, scrollToIndex (reveal/history), and prepend-anchor (direct scrollTop writes). The pure interpolation function (smoothScrollInterpolate) is unit-tested. Closes #631
Adds the visual polish layer on top of the persistent dot overlay: - Continuous rail line: a single absolutely-positioned <span> in the overlay spans from the turn's first row (Thinking) to the dot position. No per-row seams during streaming. - Rail inset: md:pl-3 on the outer TimelineRowFrame wrapper for assistant rows, shifting the rail inside the composer's visible left boundary at md+ breakpoints. - Crossfade on turn completion: when session status transitions to idle, the overlay and its rail line fade to opacity:0 over 150ms (CSS transition on data-state='completed'). After the transition, the overlay DOM is removed and per-row done-dots (always rendered underneath) become visible. Rapid turn succession cancels any pending fade. - prefers-reduced-motion disables crossfade (instant removal). Closes #633
jeonghun-jj-lee
force-pushed
the
chat-ui-polish-628
branch
from
August 28, 2026 21:05
c402165 to
df85128
Compare
…sticky at 50vh Two fixes: 1. The overlay dot was not rendering because measurementsCache is not reactive in SolidJS — the memo could read stale values before the virtualizer updated. Now uses the reactive virtualItemByKey() map first, falls back to measurementsCache, and retries on next frame if measurements aren't available yet. 2. The dot uses position: sticky with top: calc(50vh - 6.5px) inside the turn-spanning overlay, so it sticks at the viewport midpoint during long streaming responses rather than scrolling off.
…pl-3 The overlay dot and rail line were at the left edge of the full-width virtual content, but the actual per-row rail sits inside a centered, padded column (md:max-w-200 md:mx-auto md:pl-3). Fix: wrap the overlay contents in a centering div that mirrors TimelineRowFrame's classList, so LINE_X inside the overlay aligns with LINE_X inside the rows. Also: the dot uses flex-1 spacer + sticky to sit at its natural position (bottom of the turn span) and stick at 50vh when scrolling through a long response.
The overlay dot position was computed from virtualizer measurements with fragile math (pt-3, headerOffset, centering) that didn't match the actual rendered layout. Now reads getBoundingClientRect() of the target row's session-turn element relative to virtualContent — this guarantees both horizontal and vertical alignment with per-row dots. Also removed the overlay rail line entirely — per-row ThoughtRail segments already bridge their gaps with negative margins and look continuous. The overlay only provides the persistent HarmonicDot.
getBoundingClientRect-based measurement was unreliable — scroll position and timing could produce wrong results. Now reads the VirtualTimelineRow's inline style.top (which is item.start - headerOffset, set by the virtualizer) plus turnEl.offsetTop (accounts for pt-3 padding). This is deterministic and doesn't depend on scroll state or measurement timing. Horizontal left still uses getBoundingClientRect difference (scroll- independent for horizontal, and centering makes absolute offsetLeft unreliable).
…w dot - Restore margin-top: 12px on [data-component="text-part"] — gives 24px above text boxes (12px frame pt-3 + 12px margin), 12px below. Status items stay at 12px among themselves. The asymmetry encodes reading direction: text introduces, actions follow tightly. - Revert overlay dot back to per-row DotWithTooltip in ThoughtRail. The overlay approach (absolute positioning, sticky) was fragile across scroll/resize. Per-row rendering with DOM-measured dotCentre (TreeWalker + ResizeObserver) is self-correcting. - Remove all overlay infrastructure: signals, effects, JSX, CSS (.turn-overlay-dot--settled, [data-turn-overlay]). - Extract pure functions (selectUserMessages, isTimelineReady, selectVisibleUserMessages, loadOlderTimeline) into model-pure.ts to fix pre-existing model.test.ts SolidJS SSR resolution failure. - Update dotCentreForGroup: tool_group 11→16 (matches measured value for 32px collapsible triggers with 16px text). 73/73 timeline tests pass, typecheck clean, build 10.66s.
ThinkingMeta (time + tokens) now renders before the 'Interrupted' divider, attached to the last pre-interrupt output. Previously it rendered at the very end of the turn, below the divider and any post-interrupt parts. Non-interrupted turns unchanged — ThinkingMeta still renders last.
Remove the snappy 150ms prose-fragment-enter override. All streaming fragments now use the general timeline-enter animation (300ms, cubic-bezier(0.2,0,0.2,1), 8px rise + 8px blur) — the same grammar Kate's website-chat-style branch uses. The faster animation was jarring when multiple boxes arrive in rapid succession; the unified 300ms + blur provides a softer leading edge that reads as a gentle fade rather than staccato popping.
Replace blank-line chunk splitting with heading-anchored sections. Boundaries fire only at markdown headings (# through ######) outside code fences. Each card is now a complete section: heading + all its body paragraphs, code, math, and horizontal rules. Fixes: - Empty bordered cards from `---` separators - Orphan heading cards (heading alone in a tiny box) - Isolated display-math cards - Overly aggressive fragmentation on rapid streaming output The intro (text before the first heading) is its own card. Text past the last heading stays withheld as the tail during streaming.
The running dot now sits at the bottom edge of the last row instead of at the first-line dotCentre. As content streams, the row grows and the dot rides down passively — no position transitions, no re-renders, no spring animation. Cards appear above it, pushing the column up while the dot stays as the insertion cursor. Removes: - .thought-rail-dot--settled class and its top transition - [data-slot=thought-rail-line] height transition - settled prop from ThoughtRail and DotWithTooltip - dotSettled signal from TimelineRowFrame Done dots on completed rows stay at first-line centre (unchanged). Rail line for the running row extends to bottom (where dot sits).
The harmonic dot now has two positioning modes: - Thinking row (isLoneRunning): centred at dotCentre, aligned with the 'Thinking' text — it's a status signal, not growing content. - AssistantPart content rows: bottom-anchored, rides down as content grows above it. Fixes the dot sitting below the Thinking label instead of beside it.
The bottomAnchored mode now keys off a 'prose' prop (true when the row has no railLabel — i.e. it's a growing text content row). Tool rows (Shell, Edit, Read) and the Thinking row all get dotCentre positioning so the dot aligns with their label text. Only prose rows (the actively-growing text cards) get bottom-anchored.
The bottom-anchored dot on prose rows now sits 21px from the row's bottom edge (card padding 10px + half line-height 11px), aligning its centre with the last line of body text. Rail line terminates at the same inset. This is a fixed offset — the dot never jumps, it rides down as the row grows, always aligned with the last visible text line.
railLabel is only set for 'reasoning' parts — shell/edit/context groups had undefined railLabel and were incorrectly treated as prose (bottom-anchored). Now checks group.type === 'part' for prose vs shell/edit/context for tool rows. Tool rows get dotCentre positioning.
The dotCentre signal previously started at DEFAULT_DOT_CENTRE (11px) for all rows, waiting for a ResizeObserver measurement to correct it. This caused a few-pixel misalignment on tool rows where the actual first-text centre is 16px. Now initializes from dotCentreForGroup(row.group.type): - part (prose): 21px - shell/edit/context: 16px - thinking: 11px Also wires dotCentreForGroup to accept PartGroup.type values directly so the mapping is consistent.
The ResizeObserver measurement was overriding the correct dotCentreForGroup value on running tool rows. The TreeWalker found a slightly different first-text position than the calibrated 16px, causing the harmonic dot to sit a few pixels off from where the done dot would land. Now skips measurement when the row is running+last — the running dot uses either bottom-anchoring (prose) or the deterministic initial value (tools). Measurement only fires for done dots that need to align with settled content.
The previous commit skipped measurement for ALL running rows, which broke the Thinking row (it needs measurement to align with the 'Thinking' label). Now only skips for running shell/edit/context rows where the TreeWalker produces wrong results. Thinking and prose rows continue to measure normally.
An inline SVG inside a span gets vertical-align: baseline treatment, adding a ~2-3px descender gap that pushed the dot above its intended centre. display:block makes the SVG fill its wrapper exactly — no inline formatting, no gap. Also reverts the measurement-skip workaround (no longer needed — the real issue was the SVG baseline offset, not the measurement).
Move display:block from inline style (unreliable in SolidJS style merge) to the .thought-rail-dot--harmonic CSS class. This kills the inline SVG baseline gap that shifted the dot above its centre. Revert the inline style change on the component.
Force the wrapper span to exactly HARMONIC_SIZE dimensions so it can't grow beyond the SVG's bounds. This eliminates any possible sizing discrepancy between the positioned wrapper and its content.
The harmonic dot's top value wasn't updating when dotCentre changed
because the style object used a conditional spread:
...( condition ? {top: x} : {bottom: y} )
SolidJS can't trace reactivity through object spreads. Replaced with
direct property assignment (top: condition ? undefined : value).
Also removes the dead thought-rail-grow animation (was already
disconnected) and simplifies dotCentreForGroup to always return
DEFAULT_DOT_CENTRE since measurement is the source of truth.
A single shell/edit/read command has group.type 'part' (same as text), so the previous check treated it as prose and bottom-anchored it. Now looks up the actual message part: only part.type === 'text' is prose. Single tool parts get dotCentre positioning like grouped tools.
Replace the hand-rolled tooltip with the standard TooltipV2 component (portalled, themed, animated) positioned below the dot so it never occludes the rail or chat content above. Adds the ↑ arrow before token count to match the thinking-meta printout. Extract pure functions (shouldRenderRail, dotCentreForGroup) into thought-rail-pure.ts to isolate tests from Kobalte's SSR error.
Replace the randomized pulse sequence with a fixed, strictly ascending progression through quantum numbers: l=2 (m=0,1,2) → l=3 (m=0,1,2,3) → l=4 (m=0,1,2,3,4). Each level starts with its pill (m=0), then builds lobes as m increases. Higher l for the same lobe count uses a lower base offset (deeper valleys = sharper shape). - Add 6 new harmonicRadius modes (8-13) for intermediate (l,m) pairs - MODE_COUNT 8→14, PULSE_COUNT 10→12, cycle 12s→14.4s - Fixed rotation: m=2 (4-lobe) shapes at 45° (X not +), all else at 0° - Remove randomPulseSequence — HarmonicDot now uses pre-computed SMIL - Update tests to verify level ordering and base-offset progression
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.
Closes harmoniqs/amicode#628
Summary
Four UX improvements to the chat timeline:
Persistent sticky overlay dot — The HarmonicDot is hoisted from per-row rendering into a single turn-spanning overlay that lives inside the scroll container. It uses
position: sticky; top: 12pxto stay visible during long streaming responses. Only repositions on step boundaries (new rows), not per prose-fragment chunk. Spring animation (250mscubic-bezier(0.34, 1.56, 0.64, 1)) with slight overshoot on each transition. Crossfades out (150ms opacity) on turn completion.Rail alignment —
md:pl-3on assistant rows aligns the rail's left edge inside the composer's visible left boundary at medium+ breakpoints.Cohesive segments — Label heuristic prevents orphaned short labels (<40 chars ending with
:) from splitting into their own card. Empty/blank chunks are filtered before rendering (no more blank bordered cards).Smooth scroll — Custom 180ms ease-out RAF scroll replaces instant
scrollToEnd()teleport. Cancels immediately on user scroll gesture. Respectsprefers-reduced-motion.Changes
packages/app/src/pages/session/timeline/message-timeline.tsxpackages/app/src/pages/session/timeline/thought-rail.tsxdotCentreForGrouphelper, keep done-dots per-rowpackages/app/src/index.csspackages/session-ui/src/components/message-part-text.ts:) inchunkBoundaries, prevents orphaned labelspackages/session-ui/src/components/message-part.tsxChunkedStreamMarkdownAcceptance Criteria
All 16 criteria from the issue are addressed — see commit message for the full list.