feat(ui): anchor the history chart at the newest bars by default

On first mount (and on session switch, via a sessionId key remount) the
chart scrolls to the right edge so the latest steps are visible
immediately; afterwards it only sticks to the end while the user is near
it, so scrolling back into earlier turns/steps is never fought. The edge
fades follow the scroll position as before. The render test's fake React
now keeps useRef stable across renders (like React), which is what the
effect needs to see the container.
This commit is contained in:
bowenliang123 committed 2026-08-14 22:40:23 +08:00
1 parent 8f7e77d233
commit d2d0510ea0
2 files changed
+50 -6

No files matched your search

+34 -2
View File
@@ -131,7 +131,19 @@ const statefulReact = {
return slots[i]
},
useEffect: () => {},
useRef: (init) => ({ current: init }),
useRef(init) {
// Like React: the SAME ref object is returned on every render (stored
// in the fiber slot), so tree ref props and effect closures agree.
const i = hookCursor++
if (currentHooks[i] === undefined) currentHooks[i] = { ref: { current: init } }
return currentHooks[i].ref
},
useLayoutEffect(fn) {
// Store the callback in a hook slot so tests can drive the layout effect.
const i = hookCursor++
if (currentHooks[i] === undefined) currentHooks[i] = { effect: fn }
return currentHooks[i]
},
}
const requireStateful = (spec) => {
assert.equal(spec, 'react')
@@ -387,4 +399,24 @@ ctxSlots[0][1](snapshot) // restore
tr = renderView()
assert.equal(byClass(tr, 'lc-bar').length, 4, 'snapshot restored')
console.log('✔ chart render test passed (fixed-width bars, scroll container, turn ranges, hover linking, overview tooltip, turn strip, granularity toggle, edge fades, full history)')
// ---- default anchor: the newest bars sit at the right edge ----
const scrollNode = byClass(tr, 'lc-chart-scroll')[0]
const scrollEl = { scrollLeft: 0, clientWidth: 120, scrollWidth: 800 }
scrollNode.args[1].ref.current = scrollEl // attach a fake layout element
const trendKey = [...hookStates.keys()].find(k => k.includes('TrendChart'))
assert.ok(trendKey, 'TrendChart fiber registered')
const layoutEffectSlot = hookStates.get(trendKey).find(s => s && typeof s.effect === 'function')
assert.ok(layoutEffectSlot, 'chart layout effect captured')
layoutEffectSlot.effect()
assert.equal(scrollEl.scrollLeft, 800, 'initial layout anchors at the right (newest bars)')
scrollEl.scrollLeft = 200
layoutEffectSlot.effect()
assert.equal(scrollEl.scrollLeft, 200, 'scrolling away from the end is respected')
scrollEl.scrollLeft = 780
layoutEffectSlot.effect()
assert.equal(scrollEl.scrollLeft, 800, 'stays anchored at the end while near it')
tr = renderView()
assert.equal(byClass(tr, 'lc-chart-fade-l').length, 1, 'left fade shown once anchored at the newest bars')
assert.equal(byClass(tr, 'lc-chart-fade-r').length, 0, 'no right fade at the end')
console.log('✔ chart render test passed (fixed-width bars, scroll container, turn ranges, hover linking, overview tooltip, turn strip, granularity toggle, edge fades, full history, right-anchored default)')
+16 -4
View File
@@ -408,20 +408,29 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re
grp.count++
}
// Stick to the newest bars unless the user scrolled away from the end,
// and keep the edge fades in sync with the scroll position.
// Default anchor: the newest bars at the RIGHT edge. The first layout
// after mount scrolls unconditionally; afterwards the chart only sticks
// to the end while the user is already near it (scrolling away is
// respected). useLayoutEffect avoids a first-paint flash at the left.
// Edge fades stay in sync with the scroll position.
const scrollRef = React.useRef<HTMLDivElement | null>(null)
const scrolledOnce = React.useRef(false)
const [edges, setEdges] = React.useState<{ left: boolean; right: boolean }>({ left: false, right: false })
const updateEdges = (el: HTMLDivElement): void => {
const left = el.scrollLeft > 4
const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 4
setEdges(prev => (prev.left === left && prev.right === right) ? prev : { left, right })
}
React.useEffect(() => {
React.useLayoutEffect(() => {
const el = scrollRef.current
if (el === null) return
if (!scrolledOnce.current) {
scrolledOnce.current = true
el.scrollLeft = el.scrollWidth
} else if (el.scrollLeft + el.clientWidth >= el.scrollWidth - 24) {
el.scrollLeft = el.scrollWidth
}
updateEdges(el)
if (el.scrollLeft + el.clientWidth >= el.scrollWidth - 24) el.scrollLeft = el.scrollWidth
})
return h('div', { className: 'lc-chartrow' },
@@ -669,6 +678,9 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re
? h('div', { className: 'lc-empty' }, t('trend.empty'))
: h('div', null,
h(TrendChart, {
// Remount per session: switching sessions re-anchors the chart
// at the newest bars instead of inheriting stale scroll state.
key: sessionId,
// The host caps the log at 160 requests; render them ALL so
// earlier turns/steps stay reachable via horizontal scroll.
requests: displayRequests,