Skip to content

feat(app): chat UI polish — segmentation, harmonic dot, rail, scroll - #269

Merged
jeonghun-jj-lee merged 37 commits into
local/amicodefrom
chat-ui-polish-628
Aug 31, 2026
Merged

feat(app): chat UI polish — segmentation, harmonic dot, rail, scroll#269
jeonghun-jj-lee merged 37 commits into
local/amicodefrom
chat-ui-polish-628

Conversation

@jeonghun-jj-lee

@jeonghun-jj-lee jeonghun-jj-lee commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Polishes the chat timeline across four areas: prose segmentation, harmonic dot animation, thought rail, and scroll behavior.

Prose segmentation

  • Heading-anchored splitting — prose splits at headings into separate bordered cards
  • Paragraph-gap fallback — sections over 1500 chars without headings sub-split at paragraph breaks (fence-aware)

Harmonic dot animation

  • 8 genuine spherical harmonics — deterministic sequence, 9.6s cycle
  • Removed fake trefoil and duplicate-topology modes
  • Donut-path SMIL morphing (ring to solid via fill-rule evenodd)

Thought rail and dot

  • Bottom-anchored dot on prose rows, centre-measured on tool/shell rows
  • TooltipV2 (Kobalte, portalled) replacing hand-rolled tooltip
  • Unified 300ms + 8px blur entrance animation

Scroll and misc

  • Smooth scroll — 180ms RAF ease-out replacing instant teleport
  • Skill tool: pass undefined children when body is empty (fixes empty box)
  • Extracted model-pure.ts and thought-rail-pure.ts for SSR-safe pure functions

Test plan

  • Typecheck clean
  • 486 UI tests pass
  • 129 session-ui tests pass (3 pre-existing failures)
  • 897 app tests pass (11 pre-existing failures)
  • Visual testing at localhost:4444

@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c063a9e2-aa0c-457e-bf3a-21f5b2cc4057


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.

@jeonghun-jj-lee
jeonghun-jj-lee force-pushed the chat-ui-polish-628 branch 2 times, most recently from 0ef3613 to 3684a8e Compare August 31, 2026 00:00
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
…uence

Remove mode 5 '(3,0) trefoil' — its formula (1+cos3θ)/2 is a cardioid,
not a spherical harmonic (no Y_l^m has 3-fold symmetry; lobe count is
always 2m). Remove mode 11 '(4,1) deep pill' — indistinguishable from
(3,1) at 13px.

Sequence now: 10 genuine harmonics, 12s cycle.
l=2: (2,0) pinched, (2,1) pill, (2,2) clover
l=3: (3,1) pill, (3,2) clover, (3,3) rosette
l=4: (4,0) double-pinch, (4,2) clover, (4,3) rosette, (4,4) star
Replace duplicate-topology modes with 10 shapes that each look different
at 13px. No two share the same angular function — every pulse in the
cycle is unmistakably new.

Azimuthal |cos(mθ)|: pill(2), clover(4), rosette(6), star-8, star-10, star-12
Zonal P_l^0: pinched(P₂⁰), peanut(P₃⁰), double-pinch(P₄⁰), hedgehog(P₅⁰)

MODE_COUNT 14→11, all modes renumbered 0-10.
P₃⁰ (peanut) and P₅⁰ (hedgehog) look like bumpy pills at 13px.
Keep only the 2 most readable Legendres (P₂⁰ pinched, P₄⁰ double-pinch)
alongside the 6-step lobe ladder (2→4→6→8→10→12).
Sections without headings that exceed 40 lines now sub-split at
double-newline paragraph breaks. Paragraph gaps inside code fences
are ignored. Short sections (<40 lines) are unaffected.

Fixes wall-of-text rendering where a long analysis without ## headings
produced one massive card.
A skill-only turn could render an empty bordered box below the skill
chip — the text-part wrapper or prose fragment div had border + padding
but no content. Add :empty guards on [data-prose-fragment],
[data-component='text-part'], and [data-slot='session-turn-assistant-content']
so empty wrappers collapse to display:none.
SolidJS <Show> leaves comment nodes when falsy, so :empty never matches.
Use :not(:has(*)) to detect when the wrapper contains no element children
and collapse it — prevents the empty bordered box below a skill-only turn.
BasicTool's hasChildren() was truthy because the SolidJS <Show> node
counted as a child even when body() was empty. The Collapsible.Content
then rendered an empty bordered box below the skill chip.

Pass undefined children when there's no body so hasChildren() is false
and the collapsible content section doesn't render at all.
Line count was meaningless for prose — a single paragraph can be 500
characters on one raw line. Switch to character-count threshold so the
fallback fires on actual text volume, matching what the user sees.
@jeonghun-jj-lee jeonghun-jj-lee changed the title fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll feat(app): chat UI polish — segmentation, harmonic dot, rail, scroll Aug 31, 2026
@jeonghun-jj-lee
jeonghun-jj-lee marked this pull request as ready for review August 31, 2026 12:07
@jeonghun-jj-lee
jeonghun-jj-lee merged commit afca015 into local/amicode Aug 31, 2026
3 checks passed
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.

1 participant