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
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
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
stickypositioning within a turn-spanning overlay; stays visible near viewport top during long streaming responsescubic-bezier(0.34, 1.56, 0.64, 1)) with slight overshoot on each step transitionprefers-reduced-motiondisables dot spring (instant position change)Key Decisions
position: sticky; top: 12px.createMemocomputing overlay top fromvirtualizer.getVirtualItems(), updating only when row count for active turn changes. Reduces dot movements from ~20-40 per turn to ~5-10.dotCentreForGroup(groupType)— a pure function returning the constant offset. Eliminates measurement race.cubic-bezier(0.34, 1.56, 0.64, 1)gives physical character.Testing Decisions
dotCentreForGroupreturns correct constants (prose=21, tool_group=11, single_tool=16, thinking=11)HarmonicDotinstance exists in DOM (never two, never zero while running)data-timeline-enter)Constraints & Invariants
topandheightonly update when new rows appear or rows resize (step boundaries) — never per-fragmentshouldAdjustScrollPositionOnItemSizeChangeor prepend-anchor logicPrior Art
packages/app/src/pages/session/timeline/message-timeline.tsx— currentmeasureDotCentre()using ResizeObserver, dot-travel state inTimelineRowFramepackages/app/src/pages/session/timeline/thought-rail.tsx— current per-rowisRunningdot rendering,THOUGHT_RAIL_INSET,DEFAULT_DOT_CENTREpackages/app/src/index.css— current.thought-rail-dot--settledtransition (150ms ease-out)sticky top-0 z-30) as precedent for sticky elements inside the virtualizer scroll container