Skip to content

feat(app): travelling harmonic dot + bottom-up card entry animation #265

Description

@jeonghun-jj-lee

Travelling harmonic dot + bottom-up card animation

Important

Problem: During long streaming text responses, the harmonic dot anchors to the first line of the row and scrolls out of view as auto-scroll follows new content at the bottom. The loading indicator disappears precisely when the user needs it most.

Approach: Redirect dotCentre measurement to the last [data-prose-fragment] card within a running text-part row. Add CSS transitions (150ms ease-out) to the dot's top and the rail line's height so the dot glides down as new cards settle. Pair with a translateY(10px) + opacity entry animation on new prose-fragment cards, gated by auto-scroll state.

Scope: thought-rail.tsx, message-timeline.tsx (measurement logic), index.css / design-polish.css (keyframes + transitions), message-part.tsx or ChunkedStreamMarkdown (auto-scroll gate for data-part-enter).

Assumptions: Text parts remain a single AssistantPart row; the virtualizer and auto-scroll architecture are unchanged; [data-prose-fragment] card boundaries remain the unit of visual chunking.

Acceptance Criteria

  1. While streaming a text response with 2+ settled paragraph cards, the harmonic dot is visually level with the last card (not the first).
  2. The dot slides smoothly (150ms ease-out) when a new card settles — no teleport.
  3. The rail line extends in sync with the dot (same transition timing).
  4. New prose-fragment cards enter with translateY(10px) + opacity: 0 → 1 over 150ms, synchronized with the dot's slide.
  5. Card animation fires only when auto-scroll is active (user is watching). Cards that settle off-screen have no animation when scrolled into view later.
  6. prefers-reduced-motion: reduce disables all transitions and animations (instant positioning).
  7. The dot's initial mount (turn starts) does NOT trigger the slide transition — the grow animation handles first appearance.
  8. When the turn completes, the done-dot sits at the last card's position (not the first).
  9. No per-card done-dots within a row — single dot, continuous spine.

Key Decisions

Decision Rationale
Reuse measureDotCentre pipeline Already ResizeObserver-driven; redirect target from "first text node in row" to "first text node in last [data-prose-fragment]"
CSS transition, not JS animation Declarative, interruptible (fast streaming naturally redirects mid-transition), respects reduced-motion
Gate data-part-enter on auto-scroll state Prevents stale animation cascade when user scrolls back and catches up
150ms timing Matches existing thought-rail-grow timing; fast enough to not lag behind streaming cadence
data-settled gate for initial render Prevents the dot from sliding from top: 0 to its first measured position on mount

Constraints & Invariants

  • The rail's geometric contract is preserved: dot sits ON the spine, line ends AT the dot, no dangling stubs.
  • No new DOM elements — only measurement target and CSS changes.
  • Performance: no new observers (existing ResizeObserver already fires on content growth).
  • Virtualization compatibility: the dot is absolutely positioned within the row's relative container, unaffected by the virtualizer's absolute positioning of rows.

Implementation detail

Dot position tracking

Current behavior (message-timeline.tsx:1468-1486): measureDotCentre() uses a TreeWalker to find the first non-empty text node in the turn element and measures its vertical centre.

New behavior: When the row is a running text-part AssistantPart, the measurement targets the last [data-prose-fragment] element's first text line instead of the row's first text line. The ResizeObserver already re-measures on content change, so as new cards settle, dotCentre updates automatically.

measureDotCentre():
  if (running && turnEl has [data-prose-fragment] children):
    target = last [data-prose-fragment] element
    walk target's text nodes -> measure first non-empty one's centre
  else:
    existing behavior (first text node in turnEl)

Smooth dot transition

Add CSS transition to the dot when settled (not on initial mount):

.thought-rail-dot--harmonic[data-settled] {
  transition: top 150ms ease-out;
}

[data-slot="thought-rail-line"] {
  transition: height 150ms ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .thought-rail-dot--harmonic[data-settled],
  [data-slot="thought-rail-line"] {
    transition: none;
  }
}

Card entry animation

@keyframes prose-fragment-enter {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

[data-prose-fragment][data-part-enter] {
  animation: prose-fragment-enter 150ms ease-out both;
}

@media (prefers-reduced-motion: reduce) {
  [data-prose-fragment][data-part-enter] {
    animation: none;
  }
}

Gate: only set data-part-enter when auto-scroll is active (thread shouldAnchorBottom state to the text-part renderer).

Files touched

File Change
packages/app/src/pages/session/timeline/message-timeline.tsx measureDotCentre() — target last [data-prose-fragment] when running
packages/app/src/pages/session/timeline/thought-rail.tsx Add data-settled attribute after first measure; no structural change
packages/app/src/index.css Add transition: top 150ms ease-out on .thought-rail-dot--harmonic[data-settled]; transition on [data-slot="thought-rail-line"]
packages/app/src/design-polish.css @keyframes prose-fragment-enter + conditional data-part-enter
packages/session-ui/src/components/message-part.tsx (or ChunkedStreamMarkdown) Thread auto-scroll state to gate data-part-enter

Edge cases

Scenario Behavior
Single paragraph, no blank lines Dot stays at first (and only) card position. No movement until a second card settles or the turn ends.
Turn completes Dot fills to done-state. Its last position becomes the final done-dot location — aligned with the last card, not the first.
New AssistantPart row starts (tool call after prose) Dot moves to the new row per existing architecture (new row becomes lastAssistantPart). Previous row's dot becomes a done-dot at its last tracked position.
User scrolls up mid-stream Auto-scroll pauses. New cards still settle but don't get data-part-enter. Dot still moves (structural) but off-screen so invisible.
Very fast streaming (multiple chunks settle within 150ms) CSS transition naturally interrupts and redirects — dot glides to latest position without queueing.
Reduced motion No card animation, no dot transition. Everything instant.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions