Skip to content

fix(app): persistent dot overlay — turn-spanning sticky, step-boundary travel, deterministic offsets #630

Description

@jeonghun-jj-lee

Context

Part of #628 — chat UI polish.

This slice is the core structural change: hoisting the running HarmonicDot from per-row inline rendering into a single turn-spanning sticky overlay. The dot repositions only on step boundaries (not per prose-fragment), uses deterministic vertical offsets (removing the ResizeObserver measurement), and is gated to one overlay at a time.

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
  • Only one overlay dot exists at a time — gated on "is there a running turn"
  • Done-dots use deterministic offsets by group type (prose=21px, tool group=11px, single tool=16px, thinking=11px) — no TreeWalker
  • prefers-reduced-motion disables dot spring (instant position change)

Key Decisions

Decision Rationale
Sticky overlay inside scroll container Virtualization makes per-row mounts unstable; overlay avoids remount. Sticky keeps dot visible without JS position updates during long responses.
Turn-spanning wrapper div Covers from Thinking row through last AssistantPart row. HarmonicDot inside is position: sticky; top: 12px.
Position memo updates on step boundaries only Reactive createMemo computing overlay top from virtualizer.getVirtualItems(), updating only when row count for active turn changes. Reduces dot movements from ~20-40 per turn to ~5-10.
Remove ResizeObserver + measureDotCentre Replaced by dotCentreForGroup(groupType) — a pure function returning the constant offset. Eliminates measurement race.
250ms spring with overshoot Each move is an infrequent event worth marking. cubic-bezier(0.34, 1.56, 0.64, 1) gives physical character.
ThoughtRail keeps done-dots per-row Static dots for completed turns render inline as before — no overlay needed for them.

Testing Decisions

  • Unit test: dotCentreForGroup returns correct constants (prose=21, tool_group=11, single_tool=16, thinking=11)
  • Integration: during a streaming turn, only one HarmonicDot instance exists in DOM (never two, never zero while running)
  • Integration: dot does not remount on new prose-fragment chunks (component identity preserved across streaming)
  • Regression: existing entrance-animation tests still pass (overlay does not interfere with data-timeline-enter)

Constraints & Invariants

  • 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)
  • The overlay must not interfere with shouldAdjustScrollPositionOnItemSizeChange or prepend-anchor logic
  • The dot travel rule remains: stays on Thinking row until output lands, then travels to last AssistantPart row

Prior Art

  • packages/app/src/pages/session/timeline/message-timeline.tsx — current measureDotCentre() using ResizeObserver, dot-travel state in TimelineRowFrame
  • packages/app/src/pages/session/timeline/thought-rail.tsx — current per-row isRunning dot rendering, THOUGHT_RAIL_INSET, DEFAULT_DOT_CENTRE
  • packages/app/src/index.css — current .thought-rail-dot--settled transition (150ms ease-out)
  • Session header sticky pattern (sticky top-0 z-30) as precedent for sticky elements inside the virtualizer scroll container

Metadata

Metadata

Labels

afkImplementable without human interactionarea:ui

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions