fix(hud): chat panel auto-scroll on streaming append — B-06 tactical (#51) #150

Merged
KofTwentyTwo merged 1 commits from fix/hud-chat-autoscroll-v0.1 into develop 2026-05-13 11:27:43 +00:00
3 changed files with 293 additions and 1 deletions

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none';" />
<title>Jarvis HUD</title>
<script defer src="./assets/index-fBrUXkPQ.js"></script>
<script defer src="./assets/index-Btvkw81I.js"></script>
</head>
<body>
<div id="root"></div>
@@ -1,19 +1,98 @@
import { useLayoutEffect, useRef } from 'react'
import { useJarvisStore } from '../store'
import { ChatEventRouter } from './ChatEventRouter'
import './chat-panel.css'
/**
* Hysteresis (in px) for the "is user at the bottom?" check. As long as the
* scroll position is within this distance of the bottom edge, the panel is
* considered pinned and we auto-scroll on every new token. A small value is
* deliberate: streaming tokens grow scrollHeight by ~one line at a time, and
* we want the very first user-initiated scroll-up to break the pin.
*/
const BOTTOM_THRESHOLD_PX = 24
/**
* Sum the text length of all chat events. This is the streaming-progress
* signal the auto-scroll effect depends on. Using char-count means the
* effect re-fires on every appended token even though `chatEvents.length`
* stays constant during streaming (tokens append to the same text part).
*
* Cheap to compute (O(n) over chat events, each typically a short string)
* and avoids inventing a new bus message just to drive scroll.
*/
function totalContentSignal(
events: ReturnType<typeof useJarvisStore.getState>['chatEvents'],
): number {
let total = events.length // event count contributes too (non-text events)
for (const ev of events) {
if (ev.kind === 'text') total += ev.text.length
else if (ev.kind === 'tool-call') {
// Status changes (running -> completed/failed) and result text growth
// should also trigger a scroll-pin refresh. `result` is typed
// `unknown` (tool outputs are arbitrary JSON), so coerce to string.
total += ev.status.length
if (ev.result !== undefined) total += String(ev.result).length
if (ev.error) total += ev.error.length
} else if (ev.kind === 'error') {
total += ev.message.length
}
}
return total
}
/**
* ChatPanel — flat, chronological list of ChatEvents. role="log" +
* aria-live="polite" announces streamed content to screen readers.
*
* Auto-scroll behavior (B-06 / #51 tactical fix):
* - When new content arrives (new event OR a token appended to an
* existing text part), scroll to the bottom — but only if the user
* is already pinned within BOTTOM_THRESHOLD_PX of the bottom edge.
* - If the user has scrolled up to re-read, leave them alone. The
* pin auto-re-arms as soon as they scroll back down.
*
* The full fix lands in M-7 (v0.8) once the API surface emits
* `turnTextComplete` and we hydrate from `Turn.listTurns`; this is the
* pure-DOM tactical layer that fits in v0.1.
*
* No virtualization in P3 (RESEARCH §A8); P8 adds `@tanstack/react-virtual`
* if profiling shows need. Each child is keyed by event.id so React reuses
* DOM nodes across streaming updates.
*/
export function ChatPanel() {
const chatEvents = useJarvisStore((s) => s.chatEvents)
const panelRef = useRef<HTMLDivElement | null>(null)
// The content signal is `totalContentSignal(chatEvents)` — a number that
// changes on every appended token, status transition, or new event.
// useLayoutEffect runs synchronously after DOM mutation but before paint,
// so the user never sees the intermediate (pre-scroll) frame.
//
// We re-measure `distanceFromBottom` at the moment the content changed
// (rather than tracking pin state in a separate scroll handler). The
// browser always exposes current scroll position via getter properties,
// so this stays accurate even as the user scrolls without our handler
// observing it.
const contentSignal = totalContentSignal(chatEvents)
useLayoutEffect(() => {
const el = panelRef.current
if (!el) return
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
if (distanceFromBottom > BOTTOM_THRESHOLD_PX) return
// `scrollTo` is missing on JSDOM Elements (and on a few legacy
// engines). Fall back to direct scrollTop assignment so production
// never throws if the engine ships without it.
if (typeof el.scrollTo === 'function') {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
} else {
el.scrollTop = el.scrollHeight
}
}, [contentSignal])
return (
<div
ref={panelRef}
className="chat-panel"
role="log"
aria-live="polite"
@@ -0,0 +1,213 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, act } from '@testing-library/react'
import { ChatPanel } from '../src/chat/ChatPanel'
import { useJarvisStore } from '../src/store'
import type { ChatEvent } from '../src/store/types'
/**
* B-06 (issue #51) — tactical auto-scroll fix.
*
* Goal: ChatPanel scrolls to its latest content as streaming tokens arrive,
* so the user does not have to manually scroll while the agent is speaking.
*
* Heuristic: only auto-scroll when the user is already "near the bottom"
* (within a small threshold). If they have scrolled up to re-read, don't
* yank them back to the bottom.
*
* JSDOM observability note: JSDOM does not lay out, so `scrollHeight` /
* `clientHeight` / `scrollTop` are 0 by default. The tests below stub those
* properties on the scroll container so we can assert the auto-scroll
* effect's decision logic — which is the behavior under test. Empirical
* pixel-level scroll verification is deferred to the M-7 typed-API work
* (per the issue's own scope note).
*/
function resetStore() {
useJarvisStore.setState({
hudState: 'booting',
chatEvents: [],
a11y: { reduceMotion: false, reduceTransparency: false },
theme: { arcReactorGlow: '#1E88E5' },
connection: 'booting',
})
}
// Stub layout properties on an element. JSDOM treats these as ordinary
// getters; redefining lets us simulate a scrollable container.
function stubLayout(
el: HTMLElement,
opts: { scrollHeight: number; clientHeight: number; scrollTopGetter: () => number },
) {
Object.defineProperty(el, 'scrollHeight', {
configurable: true,
get: () => opts.scrollHeight,
})
Object.defineProperty(el, 'clientHeight', {
configurable: true,
get: () => opts.clientHeight,
})
Object.defineProperty(el, 'scrollTop', {
configurable: true,
get: opts.scrollTopGetter,
set: () => {
/* mutation handled by stubScrollTo */
},
})
}
function getPanel(container: HTMLElement): HTMLElement {
const el = container.querySelector('[role="log"]') as HTMLElement | null
if (!el) throw new Error('chat-panel not found')
return el
}
describe('ChatPanel auto-scroll on streaming append (B-06 / #51)', () => {
beforeEach(() => resetStore())
it('AS1: scrolls to bottom when a new chat event is appended and user is at bottom', () => {
// Start empty so we can attach stubs to the panel element before any
// streaming-driven layout queries happen.
const { container } = render(<ChatPanel />)
const panel = getPanel(container)
const scrollToSpy = vi.fn()
panel.scrollTo = scrollToSpy as unknown as typeof panel.scrollTo
// User is at the bottom: scrollTop + clientHeight == scrollHeight.
stubLayout(panel, {
scrollHeight: 1000,
clientHeight: 200,
scrollTopGetter: () => 800,
})
act(() => {
useJarvisStore.setState({
chatEvents: [
{ id: 'e1', kind: 'text', text: 'hello', role: 'assistant', turnId: 't1' },
] as ChatEvent[],
})
})
expect(scrollToSpy).toHaveBeenCalled()
const arg = scrollToSpy.mock.calls.at(-1)?.[0] as ScrollToOptions
expect(arg.top).toBe(1000)
})
it('AS2: scrolls on streaming token append (same event, growing text)', () => {
const base: ChatEvent = {
id: 'turn:t1:assistant',
kind: 'text',
text: '',
role: 'assistant',
turnId: 't1',
}
act(() => {
useJarvisStore.setState({ chatEvents: [base] })
})
const { container, rerender } = render(<ChatPanel />)
const panel = getPanel(container)
const scrollToSpy = vi.fn()
panel.scrollTo = scrollToSpy as unknown as typeof panel.scrollTo
// Simulate a growing scrollHeight as tokens stream in.
let scrollHeight = 1000
Object.defineProperty(panel, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
})
Object.defineProperty(panel, 'clientHeight', {
configurable: true,
get: () => 200,
})
Object.defineProperty(panel, 'scrollTop', {
configurable: true,
get: () => scrollHeight - 200, // pinned to bottom
set: () => {
/* noop */
},
})
scrollToSpy.mockClear()
for (const delta of ['h', 'e', 'l', 'l', 'o']) {
scrollHeight += 20
act(() => {
useJarvisStore.getState().appendTokenToLastText('turn:t1:assistant', delta)
})
rerender(<ChatPanel />)
}
// Once per token append.
expect(scrollToSpy.mock.calls.length).toBeGreaterThanOrEqual(5)
const lastArg = scrollToSpy.mock.calls.at(-1)?.[0] as ScrollToOptions
expect(lastArg.top).toBe(scrollHeight)
})
it('AS3: does NOT scroll when user has scrolled up away from the bottom', () => {
const { container } = render(<ChatPanel />)
const panel = getPanel(container)
const scrollToSpy = vi.fn()
panel.scrollTo = scrollToSpy as unknown as typeof panel.scrollTo
// User scrolled up: 500 px below the bottom (way beyond the small
// hysteresis threshold used by the auto-scroll effect).
stubLayout(panel, {
scrollHeight: 1000,
clientHeight: 200,
scrollTopGetter: () => 300, // 1000 - 200 - 300 = 500 px from bottom
})
act(() => {
useJarvisStore.setState({
chatEvents: [
{ id: 'e1', kind: 'text', text: 'noisy', role: 'assistant', turnId: 't1' },
] as ChatEvent[],
})
})
expect(scrollToSpy).not.toHaveBeenCalled()
})
it('AS4: resumes auto-scroll once the user scrolls back to the bottom', () => {
const { container } = render(<ChatPanel />)
const panel = getPanel(container)
const scrollToSpy = vi.fn()
panel.scrollTo = scrollToSpy as unknown as typeof panel.scrollTo
// Start scrolled up — `scrollTop` is mutable here so we can simulate
// the user scrolling back to the bottom between content updates.
// The implementation re-measures DOM at the moment of each content
// change, so no manual `scroll` event dispatch is needed for pin
// state to update.
let scrollTop = 300
stubLayout(panel, {
scrollHeight: 1000,
clientHeight: 200,
scrollTopGetter: () => scrollTop,
})
act(() => {
useJarvisStore.setState({
chatEvents: [
{ id: 'e1', kind: 'text', text: 'first', role: 'assistant', turnId: 't1' },
] as ChatEvent[],
})
})
expect(scrollToSpy).not.toHaveBeenCalled()
// User scrolled back to the bottom. Next content update re-measures
// distanceFromBottom = 0 and resumes auto-scroll.
scrollTop = 800
act(() => {
useJarvisStore.setState({
chatEvents: [
{ id: 'e1', kind: 'text', text: 'first', role: 'assistant', turnId: 't1' },
{ id: 'e2', kind: 'text', text: 'second', role: 'assistant', turnId: 't1' },
] as ChatEvent[],
})
})
expect(scrollToSpy).toHaveBeenCalled()
})
})