fix(hud): chat panel auto-scroll on streaming append — B-06 tactical (#51) #150
No files matched your search
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user