Skip to content

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

Description

@jeonghun-jj-lee

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

Important

Problem — The chat timeline has four UX gaps that break the "seamless messaging" feel: (1) the spherical harmonics dot remounts/jumps on every new step and jitters during rapid streaming instead of staying calm, (2) the rail sits outside the composer's visible left edge, (3) prose messages over-segment into blank/orphaned boxes (labels split from their lists), and (4) the column teleports when new content arrives instead of gliding.

Approach — Hoist the running dot into a sticky-positioned turn-spanning overlay that only repositions on step boundaries; add left inset to align the rail inside the composer; strengthen the chunk-boundary heuristic to keep labels with their content; replace instant scrollToEnd() with a custom 180ms eased scroll.

Scope — Timeline rendering, scroll behaviour, and chunk segmentation. No backend, no protocol, no new dependencies.

Assumptions — Virtualization stays (tanstack/solid-virtual); existing entrance animations (300ms/8px rise/8px blur, 150ms prose-fragment) unchanged; overlay position driven by reactive memo from virtualizer measurements, updating only on step boundaries.

Acceptance Criteria

  • The HarmonicDot is a single persistent element per turn — never remounts while running
  • The dot uses CSS sticky positioning within a turn-spanning overlay; stays visible near viewport top during long streaming responses
  • The dot only repositions on step boundaries (new rows: tool start, tool end, new text part) — NOT per prose-fragment chunk
  • Dot slide uses a 250ms spring animation (cubic-bezier(0.34, 1.56, 0.64, 1)) with slight overshoot on each step transition
  • On turn completion, the overlay dot crossfades out (150ms opacity) and the per-row done-dot appears underneath
  • The rail line for the active turn is continuous (no per-row seams/gaps visible during streaming)
  • The rail's left edge sits inside the composer's visible left boundary at md+ breakpoints
  • Done-dots use deterministic offsets by group type (prose=21px, tool group=11px, single tool=16px, thinking=11px) — no TreeWalker
  • A short label (<40 chars ending with :) followed by any content stays in one card
  • Consecutive list items / blockquotes never split (existing guard preserved)
  • No blank/empty bordered cards render in the timeline
  • New content arriving while anchored to bottom causes a smooth ~180ms ease-out scroll, not a teleport
  • Smooth scroll cancels immediately on user scroll gesture
  • Entrance animations unchanged (300ms timeline-enter, 150ms prose-fragment-enter, concurrent with scroll)
  • prefers-reduced-motion disables smooth scroll, dot spring, and crossfade (instant fallbacks)
  • Only one overlay dot exists at a time — gated on "is there a running turn"

Key Decisions

Decision Rationale
Sticky overlay (not per-row dot) Virtualization makes per-row mounts unstable; overlay avoids remount. Sticky keeps dot visible during long responses without JS position updates.
Track per-row, not per-fragment Reduces dot movements from ~20-40 per turn to ~5-10. Each move marks a real phase change (tool→prose→tool). Eliminates jitter during rapid streaming.
250ms spring with overshoot for dot slide Each move is now an infrequent event worth marking. cubic-bezier(0.34, 1.56, 0.64, 1) gives physical character.
Crossfade on turn completion (not morph) Done-dot renders underneath; fade-out is 150ms, no SMIL choreography needed.
Reactive memo for overlay bounds (not coupled to scroll RAF) Simpler; only updates on step boundaries; one-frame lag imperceptible at 60fps.
Deterministic dotCentre — hardcoded per group type Eliminates measurement race; if CSS padding changes, one constant to update (accepted tradeoff).
Custom RAF scroll, 180ms ease-out Native behavior: 'smooth' is 500ms+ and browser-dependent; custom gives snappy control.
Entrance + scroll run concurrently Feels natural — column makes room while message arrives.
Left padding on row frame (md:pl-3) Simplest fix; narrows content from the left only, aligns rail with composer interior.
Label heuristic: <40 chars ending : + any content Catches orphaned labels (common) without false-positiving on mid-sentence colons (length gate). Doesn't require next line to be a list — also protects label+paragraph.

Constraints & Invariants

  • The overlay lives inside the scroll container (scrolls with content). A turn-spanning wrapper div covers from the Thinking row's item.start to the last AssistantPart row's item.start + item.size. HarmonicDot inside it is position: sticky; top: 12px.
  • Overlay top and height only update when new rows appear or rows resize (step boundaries) — never per-fragment.
  • Done-dots remain per-row (static, no overlay needed).
  • Smooth scroll must not interfere with shouldAdjustScrollPositionOnItemSizeChange or prepend-anchor logic.
  • Chunk splitting remains monotonic (once a boundary is emitted during streaming, it never un-emits).
  • The 40-char label heuristic fires regardless of what follows (list, paragraph, quote) — the length gate prevents false positives on real sentences.
  • Rail line + dot spring use identical timing (250ms, same bezier) so they move in lockstep.

Files

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/design-polish.css No motion token changes
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, tail trim
packages/session-ui/src/components/message-part.tsx Chunk trimming in ChunkedStreamMarkdown

Prior Art

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions