diff --git a/docs/img.png b/docs/img.png new file mode 100644 index 0000000..d6ea503 Binary files /dev/null and b/docs/img.png differ diff --git a/scripts/test-client.mjs b/scripts/test-client.mjs index 3b95fce..c9470db 100644 --- a/scripts/test-client.mjs +++ b/scripts/test-client.mjs @@ -140,8 +140,9 @@ const statefulReact = { }, useLayoutEffect(fn) { // Store the callback in a hook slot so tests can drive the layout effect. + // Always overwrite: real React runs the LATEST render's closure. const i = hookCursor++ - if (currentHooks[i] === undefined) currentHooks[i] = { effect: fn } + currentHooks[i] = { effect: fn } return currentHooks[i] }, } @@ -456,6 +457,26 @@ 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') +// ---- granularity switches re-anchor at the newest bars ---- +// (the turn->step report: returning to step must show the right edge) +const latestEffect = () => hookStates.get(trendKey).find(s => s && typeof s.effect === 'function') +scrollEl.scrollLeft = 0 // stale left edge from the narrow turn chart +const granTurnBtn = byClass(tr, 'lc-gran-btn')[1] +granTurnBtn.args[1].onClick() // step -> turn +tr = renderView() +latestEffect().effect() +assert.equal(scrollEl.scrollLeft, 800, 'switching to turn re-anchors at the newest bars') +scrollEl.scrollLeft = 0 +const granStepBtn = byClass(tr, 'lc-gran-btn')[0] +granStepBtn.args[1].onClick() // turn -> step +tr = renderView() +latestEffect().effect() +assert.equal(scrollEl.scrollLeft, 800, 'switching back to step re-anchors at the newest bars') +// a plain re-render (poll) without a switch must NOT yank a scrolled-away view +scrollEl.scrollLeft = 200 +latestEffect().effect() +assert.equal(scrollEl.scrollLeft, 200, 'plain re-renders still respect the scroll position') + // ---- message list: newest first, with timestamps when available ---- const nodeRows = byClass(tr, 'lc-node') assert.equal(nodeRows.length, 2, 'message rows rendered') diff --git a/src/client.cts b/src/client.cts index c78fec8..255aab2 100644 --- a/src/client.cts +++ b/src/client.cts @@ -288,6 +288,8 @@ interface TrendChartProps { hoveredSeq: number | null /** The turn currently highlighted (from the turn strip or a hovered bar). */ activeTurn: number | null + /** Display granularity; a switch re-anchors the chart at the newest bars. */ + granularity: 'step' | 'turn' onSelect: (seq: number | null) => void onHover: (seq: number | null) => void onHoverTurn: (turn: number | null) => void @@ -457,12 +459,15 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re } // 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. + // after mount scrolls unconditionally; a GRANULARITY SWITCH re-anchors + // the same way (returning to step mode must show the newest bars, not + // the stale left edge from the narrow turn chart); otherwise 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 lastGranRef = React.useRef(props.granularity) const [edges, setEdges] = React.useState<{ left: boolean; right: boolean }>({ left: false, right: false }) const updateEdges = (el: HTMLDivElement): void => { const left = el.scrollLeft > 4 @@ -472,6 +477,10 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re React.useLayoutEffect(() => { const el = scrollRef.current if (el === null) return + if (props.granularity !== lastGranRef.current) { + lastGranRef.current = props.granularity + scrolledOnce.current = false // re-anchor on every granularity switch + } if (!scrolledOnce.current) { scrolledOnce.current = true el.scrollLeft = el.scrollWidth @@ -763,6 +772,7 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re selectedSeq: pinnedReq ? pinnedReq.seq : null, hoveredSeq, activeTurn, + granularity, onSelect: setSelectedSeq, onHover: setHoveredSeq, onHoverTurn: setHoverTurn,