Skip to content

fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll - #269

Draft
jeonghun-jj-lee wants to merge 27 commits into
local/amicodefrom
chat-ui-polish-628
Draft

fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll#269
jeonghun-jj-lee wants to merge 27 commits into
local/amicodefrom
chat-ui-polish-628

Conversation

@jeonghun-jj-lee

Copy link
Copy Markdown
Contributor

Closes harmoniqs/amicode#628

Summary

Four UX improvements to the chat timeline:

  1. 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: 12px to stay visible during long streaming responses. Only repositions on step boundaries (new rows), not per prose-fragment chunk. Spring animation (250ms cubic-bezier(0.34, 1.56, 0.64, 1)) with slight overshoot on each transition. Crossfades out (150ms opacity) on turn completion.

  2. Rail alignmentmd:pl-3 on assistant rows aligns the rail's left edge inside the composer's visible left boundary at medium+ breakpoints.

  3. 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).

  4. Smooth scroll — Custom 180ms ease-out RAF scroll replaces instant scrollToEnd() teleport. Cancels immediately on user scroll gesture. Respects prefers-reduced-motion.

Changes

File Changes
packages/app/src/pages/session/timeline/message-timeline.tsx Turn-spanning sticky overlay component, position memo (step-boundary only), smooth scroll helper, left padding on TimelineRowFrame, remove TreeWalker/ResizeObserver measurement
packages/app/src/pages/session/timeline/thought-rail.tsx Remove running-dot from per-row render, export deterministic dotCentreForGroup helper, keep done-dots per-row
packages/app/src/index.css Update dot transition to 250ms spring bezier, overlay sticky styles, crossfade class
packages/session-ui/src/components/message-part-text.ts Label heuristic (<40 chars + :) in chunkBoundaries, prevents orphaned labels
packages/session-ui/src/components/message-part.tsx Empty chunk filtering in ChunkedStreamMarkdown

Acceptance Criteria

All 16 criteria from the issue are addressed — see commit message for the full list.

@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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
…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
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.

fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll

1 participant