ChatPanel now pins to the bottom as streaming tokens arrive, so the user
no longer has to manually scroll while the agent is speaking (B-06).
Heuristic:
- Compute a content signal = total char-length across all chat events.
Token deltas append to the same text part (stable event id), so a
length-based signal — not array-length — is what changes on every
streamed chunk.
- A useLayoutEffect keyed on that signal re-measures distanceFromBottom
at the moment the content mutates. If the user is within 24 px of the
bottom we scroll to scrollHeight via scrollTo({ behavior: 'smooth' }).
- If the user scrolled up to re-read, the pin is broken (distance > 24
px) and we leave them alone. The pin auto-re-arms the next time they
scroll back to the bottom — no event listener required, since we
re-measure DOM on every content change.
- scrollTo is feature-detected (JSDOM lacks it; fall back to direct
scrollTop assignment so production never throws).
Scope: tactical, pure-DOM. The full fix lands in M-7 (v0.8) once the API
surface emits turnTextComplete and the HUD hydrates from Turn.listTurns;
this is the consumer-side scroll behavior that fits in v0.1.
Tests: 4 new vitest cases in ChatPanel.autoscroll.test.tsx covering:
- AS1: new event when pinned -> scrollTo top=scrollHeight.
- AS2: streaming token append (same event id) -> scrollTo per token.
- AS3: user scrolled up -> no scrollTo.
- AS4: user scrolled back to bottom -> next append resumes scroll.
DOM-layout properties (scrollHeight/clientHeight/scrollTop) are stubbed
via Object.defineProperty because JSDOM does not lay out. Empirical
pixel-level scroll verification is deferred to the M-7 typed-API work
per the issue's own scope note.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>