From d2d0510ea01643f86edbc62eaa795c1d6dff401f Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Fri, 14 Aug 2026 22:40:23 +0800 Subject: [PATCH] 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. --- scripts/test-client.mjs | 36 ++++++++++++++++++++++++++++++++++-- src/client.cts | 20 ++++++++++++++++---- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/scripts/test-client.mjs b/scripts/test-client.mjs index 3a7d341..88bb908 100644 --- a/scripts/test-client.mjs +++ b/scripts/test-client.mjs @@ -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)') diff --git a/src/client.cts b/src/client.cts index 45c334e..bd88c64 100644 --- a/src/client.cts +++ b/src/client.cts @@ -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(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,