Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# FlowChat History Paging

The anchor renews its settle budget only when a correction reduces its measured
residual (or reaches tolerance), or while a missing Turn is still awaited.
An ineffective correction is remembered for that exact anchor/viewport geometry;
it is retried after geometry changes or a new anchor is captured, not on every
frame. Identical resize notifications do not renew the budget. The comparison
includes the anchor's content coordinate, so displacement at unchanged total
height still opens a settle. Item changes, snapshot restores and host resumes
remain explicit settle triggers. No sign-change count or overall time limit
terminates valid corrections.

Older Turns are fetched when the reader approaches the head of the loaded
window, prepended above them, and paid for by moving the viewport down by
exactly what arrived. This document covers the whole of that: when the ask goes
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# FlowChat Viewport Register

Anchor diagnostics distinguish `anchor.correct` (a requested shift) from
`anchor.correctionResult` (actual scroll travel, residual and progress). Results
include the trigger source, anchor row key, rendered first index/count, frame
timestamp, and cumulative correction/travel/reversal counts since capture.
Reversals are evidence, not a stop condition. `anchor.settleOpened` records
items/resize/snapshot/resume triggers and the previous budget; `anchor.settleEnded`
records the final outcome. Repeating events use the existing gated coalescer;
none contain message or tool content. Read suppressed counts as well as emitted
samples. These diagnostics do not prove that a correction painted on screen.

Every deliberate write to the FlowChat scroller goes through one register, which
decides whether the writer may act. This document covers the register, what it
replaced, the two writers that stand outside it, and the trail it leaves.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# FlowChat Virtualization

## Measured row spacing

`VirtualItemRenderer` establishes a `flow-root` on the measured wrapper so
descendant block margins remain inside its border box. A row replaced by virtual
padding must retain its full occupied height, including trailing item spacing.
Do not substitute clipping overflow: user-message decorations and controls can
extend outside the reading column. The rhythm test protects this stylesheet
contract; it does not prove browser layout or end-to-end scroll stability.

## Interrupted turn continuity

Cancelled rounds remain in the ordinary transcript. The display projection removes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ function readSource(relativePath: string): string {
}

describe('FlowChat transcript rhythm', () => {
it('contains descendant spacing within the measured virtual row without clipping controls', () => {
const styles = readSource('./VirtualItemRenderer.scss');
const wrapper = styles.slice(styles.indexOf('.virtual-item-wrapper {'), styles.indexOf("&[data-item-type='user-message']"));

// This is a stylesheet contract; browser margin geometry needs real layout.
expect(wrapper).toContain('display: flow-root;');
expect(wrapper).not.toMatch(/overflow(?:-x|-y)?:\s*(?:hidden|clip|auto|scroll)\s*;/);
});

function modelRound(
turnId: string,
roundId: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
@include markdownTypography.apply;
@include transcriptLayout.reading-column;

// Keep descendant margins inside the border box measured by the virtualizer.
// Escaped margins otherwise disappear when a row is replaced by virtual padding.
// Unlike overflow: hidden, this preserves outlying decorations and controls.
display: flow-root;

/*
* Single reading column shared by every item type (model rounds, explore
* groups, user messages, notices). Matches ChatInput's 900px so headers,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -926,7 +926,7 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
]);

useLayoutEffect(() => {
viewportAnchor.openSettleWindow();
viewportAnchor.openSettleWindow('items');
}, [viewportAnchor, virtualItems]);

const updateVisibleTurnInfoFromViewport = useCallback(() => {
Expand Down Expand Up @@ -1143,7 +1143,7 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
// Seed the ordinary settle loop from the restored relationship so later
// virtual-item measurements keep the same Turn at the same viewport offset.
viewportAnchor.captureAnchor();
viewportAnchor.openSettleWindow();
viewportAnchor.openSettleWindow('snapshot');
traceViewport({
location: 'viewport.sessionSnapshotRestored',
message: 'FlowChat restored a session anchor from its semantic snapshot',
Expand Down Expand Up @@ -1479,7 +1479,7 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
});
restoredScrollTopFallback = true;
}
viewportAnchor.openSettleWindow();
viewportAnchor.openSettleWindow('resume');
}

traceViewport({
Expand Down Expand Up @@ -1718,7 +1718,7 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
if (viewportBoxChanged) {
viewportAnchor.captureAnchor();
} else {
viewportAnchor.openSettleWindow();
viewportAnchor.openSettleWindow('resize');
}

if (tailRealignCallbacksRef.current > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
* to hold; this one fires once per wait and its numbers are the whole point of
* it, so it is worth pinning that it fires exactly once and says the truth.
*/
const mocks = vi.hoisted(() => ({ traceViewport: vi.fn() }));
const mocks = vi.hoisted(() => ({ traceViewport: vi.fn(), traceViewportRepeating: vi.fn() }));
vi.mock('@/infrastructure/diagnostics/flowChatViewportDiagnostics', async () => {
const actual = await vi.importActual<
typeof import('@/infrastructure/diagnostics/flowChatViewportDiagnostics')
>('@/infrastructure/diagnostics/flowChatViewportDiagnostics');
return { ...actual, traceViewport: mocks.traceViewport };
return { ...actual, traceViewport: mocks.traceViewport, traceViewportRepeating: mocks.traceViewportRepeating };
});

const VIEWPORT_HEIGHT = 500;
Expand Down Expand Up @@ -106,6 +106,7 @@ describe('useFlowChatViewportAnchor', () => {

beforeEach(() => {
mocks.traceViewport.mockReset();
mocks.traceViewportRepeating.mockReset();
container = document.createElement('div');
scroller = document.createElement('div');
document.body.append(container, scroller);
Expand Down Expand Up @@ -584,6 +585,76 @@ describe('useFlowChatViewportAnchor', () => {
expect(scroller.scrollTop).toBe(0);
});

it('does not renew the frame budget for identical resize notifications', () => {
layoutTurns({ 'turn-3': 100 });
api.captureAnchor();
api.openSettleWindow('resize');
for (let i = 0; i < ANCHOR_SETTLE_FRAMES; i += 1) {
api.openSettleWindow('resize');
runFrame();
}
expect(frames).toHaveLength(0);
// Same total height does not mean the anchored content stayed put.
layoutTurns({ 'turn-3': 120 });
api.openSettleWindow('resize');
expect(scroller.scrollTop).toBe(20);
expect(frames).toHaveLength(1);
});

it('stops retrying a clamped correction until the geometry changes', () => {
let top = 0;
const writes = vi.fn((value: number) => { top = Math.max(0, value); });
Object.defineProperty(scroller, 'scrollTop', {
configurable: true, get: () => top, set: writes,
});
layoutTurns({ 'turn-3': 100 });
api.captureAnchor();
layoutTurns({ 'turn-3': 80 });
api.openSettleWindow();
for (let i = 0; i < ANCHOR_SETTLE_FRAMES; i += 1) runFrame();
expect(writes).toHaveBeenCalledTimes(1);
expect(frames).toHaveLength(0);
// A real displacement makes a new, now satisfiable correction.
layoutTurns({ 'turn-3': 120 });
api.openSettleWindow('resize');
expect(scroller.scrollTop).toBe(20);
expect(writes).toHaveBeenCalledTimes(2);
});

it('continues partial corrections when they reduce the residual', () => {
let top = 0;
Object.defineProperty(scroller, 'scrollTop', {
configurable: true, get: () => top,
set: (value: number) => { top += (value - top) / 2; },
});
layoutTurns({ 'turn-3': 100 });
api.captureAnchor();
layoutTurns({ 'turn-3': 108 });
api.openSettleWindow();
for (let i = 0; i < 4; i += 1) runFrame();
expect(Math.abs(scroller.scrollTop - 8)).toBeLessThan(0.5);
});

it('reports reversals without suppressing valid late layout corrections', () => {
layoutTurns({ 'turn-3': 100 });
api.captureAnchor();
api.openSettleWindow('items');
for (let i = 0; i < 30; i += 1) {
layoutTurns({ 'turn-3': i % 2 === 0 ? 108 : 100 });
runFrame();
}
expect(scroller.scrollTop).toBe(0);
const results = mocks.traceViewportRepeating.mock.calls
.map(([, probe]) => probe).filter(probe => probe.location === 'anchor.correctionResult');
expect(results).toHaveLength(30);
expect(results.at(-1)?.data()).toMatchObject({
source: 'items', requestedPx: -8, appliedPx: -8, residualPx: 0,
improved: true, correctionCount: 30, reversalCount: 29, cumulativeTravelPx: 240,
});
for (let i = 0; i < ANCHOR_SETTLE_FRAMES; i += 1) runFrame();
expect(frames).toHaveLength(0);
});

it('winds the settle window down while another owner holds the viewport', () => {
/*
* Measured: 27 seconds of `anchor.stoodDown`, one per frame, with the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ type AnchorRestoreOutcome =
| 'in-place'
| 'awaiting-turn'
| 'stood-down'
| 'blocked'
| 'no-anchor';

export interface FlowChatViewportAnchorApi {
Expand Down Expand Up @@ -180,7 +181,7 @@ export interface FlowChatViewportAnchorApi {
* but where it is, the displacement and its repair are one paint instead of
* two.
*/
openSettleWindow: () => void;
openSettleWindow: (source?: 'items' | 'resize' | 'snapshot' | 'resume') => void;
}

export function useFlowChatViewportAnchor({
Expand Down Expand Up @@ -231,6 +232,10 @@ export function useFlowChatViewportAnchor({
const lastUserScrollIntentAtRef = useRef(0);
/** Frames left in which the anchor is still being re-asserted. */
const settleFramesRef = useRef(0);
const blockedGeometryRef = useRef<string | null>(null);
const lastResizeGeometryRef = useRef<string | null>(null);
const correctionStatsRef = useRef({ count: 0, reversals: 0, travelPx: 0, lastSign: 0 });
const settleSourceRef = useRef<string>('items');
/**
* The next settle window takes a reading position instead of restoring one.
*
Expand Down Expand Up @@ -318,6 +323,9 @@ export function useFlowChatViewportAnchor({
const captureAnchorAt = useCallback((source: 'explicit' | 'scroll' | 'navigation') => {
const scroller = scrollerRef.current;
if (!scroller) return;
blockedGeometryRef.current = null;
lastResizeGeometryRef.current = null;
correctionStatsRef.current = { count: 0, reversals: 0, travelPx: 0, lastSign: 0 };
const previous = anchorRef.current;
const next = selectViewportAnchor(
readViewportAnchorCandidates(scroller),
Expand Down Expand Up @@ -570,9 +578,16 @@ export function useFlowChatViewportAnchor({
carryAnchorThroughScroll(scroller, 'viewport-moved');
}
const rebased = anchorRef.current ?? anchor;
const currentOffsetPx = readTurnAnchorOffsetPx(scroller, element);
const geometry = JSON.stringify([
rebased.turnId, rebased.offsetFromScrollerTop, currentOffsetPx,
scroller.scrollTop, scroller.scrollHeight, scroller.clientHeight, scroller.clientWidth,
]);
if (blockedGeometryRef.current === geometry) return 'blocked';
blockedGeometryRef.current = null;
const correction = viewportAnchorCorrectionPx(
rebased,
readTurnAnchorOffsetPx(scroller, element),
currentOffsetPx,
);
/*
* Already where it belongs, which still counts as answered — and is now the
Expand Down Expand Up @@ -609,7 +624,7 @@ export function useFlowChatViewportAnchor({
const felt = Math.abs(correction) >= ANCHOR_NOTABLE_CORRECTION_PX;
traceViewportRepeating(`anchor|correcting|${felt ? 'felt' : 'rounding'}|${rebased.turnId}`, {
location: 'anchor.correct',
message: 'anchor put the reading position back',
message: 'anchor requested a reading position correction',
travelPx: correction,
data: () => ({
turnId: rebased.turnId,
Expand Down Expand Up @@ -644,15 +659,52 @@ export function useFlowChatViewportAnchor({
scrollRangePx: roundViewportPx(scroller.scrollHeight),
}),
});
const beforePx = scroller.scrollTop;
shiftViewportRef.current(correction);
const appliedPx = scroller.scrollTop - beforePx;
const residualPx = viewportAnchorCorrectionPx(rebased, readTurnAnchorOffsetPx(scroller, element));
const improved = Math.abs(residualPx) < ANCHOR_CORRECTION_EPSILON_PX
|| Math.abs(residualPx) < Math.abs(correction);
const stats = correctionStatsRef.current;
const sign = Math.sign(correction);
if (stats.lastSign !== 0 && stats.lastSign !== sign) stats.reversals += 1;
stats.lastSign = sign;
stats.count += 1;
stats.travelPx += Math.abs(appliedPx);
const magnitudeBand = Math.abs(correction) >= ANCHOR_NOTABLE_CORRECTION_PX ? 'felt' : 'rounding';
traceViewportRepeating(`anchor|result|${rebased.turnId}|${improved}|${sign}|${magnitudeBand}`, {
location: 'anchor.correctionResult',
message: 'measured the result of an anchor correction',
travelPx: appliedPx,
data: () => ({
turnId: rebased.turnId, source: settleSourceRef.current,
frameStartMs: correctionFrameStartMsRef.current,
requestedPx: roundViewportPx(correction), appliedPx: roundViewportPx(appliedPx),
residualPx: roundViewportPx(residualPx), improved,
correctionCount: stats.count, reversalCount: stats.reversals,
cumulativeTravelPx: roundViewportPx(stats.travelPx),
scrollTopPx: roundViewportPx(scroller.scrollTop), scrollHeightPx: scroller.scrollHeight,
viewportHeightPx: scroller.clientHeight,
anchorItemKey: element.dataset.virtualItemKey ?? null,
renderedFirstIndex: scroller.querySelector('[data-virtual-index]')?.getAttribute('data-virtual-index') ?? null,
renderedRowCount: scroller.querySelectorAll('[data-virtual-index]').length,
}),
});
if (!improved) {
blockedGeometryRef.current = JSON.stringify([
rebased.turnId, rebased.offsetFromScrollerTop,
readTurnAnchorOffsetPx(scroller, element), scroller.scrollTop,
scroller.scrollHeight, scroller.clientHeight, scroller.clientWidth,
]);
}
/*
* The shift put the Turn back at the stored offset, so advancing only the
* position keeps the two halves agreeing. This is the one movement of
* `scrollTop` that must not be taken into the offset — it is the repair.
*/
anchorScrollTopRef.current = scroller.scrollTop;
isRestoringViewportResumeRef.current = false;
return 'corrected';
return improved ? 'corrected' : 'blocked';
}, [carryAnchorThroughScroll, reportMissingTurnWait, scrollerRef]);

const attemptRestoreRef = useRef(attemptRestore);
Expand All @@ -674,7 +726,23 @@ export function useFlowChatViewportAnchor({
return outcome === 'corrected' || outcome === 'in-place';
}, []);

const openSettleWindow = useCallback(() => {
const openSettleWindow = useCallback((source: 'items' | 'resize' | 'snapshot' | 'resume' = 'items') => {
const scroller = scrollerRef.current;
if (source === 'resize' && scroller) {
const anchor = anchorRef.current;
const element = anchor ? findRenderedTurnAnchorElement(scroller, anchor.turnId) : null;
const geometry = JSON.stringify([
scroller.scrollHeight, scroller.clientWidth, scroller.clientHeight,
anchor?.turnId, element ? readTurnAnchorOffsetPx(scroller, element) + scroller.scrollTop : null,
]);
if (lastResizeGeometryRef.current === geometry) return;
lastResizeGeometryRef.current = geometry;
}
settleSourceRef.current = source;
traceViewportRepeating(`anchor|settle-open|${source}`, {
location: 'anchor.settleOpened', message: 'opened an anchor settle window',
data: () => ({ source, remainingFrames: settleFramesRef.current, alreadyRunning: settleFrameRef.current !== null }),
});
settleFramesRef.current = ANCHOR_SETTLE_FRAMES;
if (recaptureOnNextSettleRef.current) {
/*
Expand Down Expand Up @@ -736,10 +804,15 @@ export function useFlowChatViewportAnchor({
correctionFrameStartMsRef.current = null;
if (settleFramesRef.current > 0) {
settleFrameRef.current = requestAnimationFrame(step);
} else {
traceViewportRepeating(`anchor|settle-ended|${outcome}`, {
location: 'anchor.settleEnded', message: 'anchor settle window ended',
data: () => ({ source: settleSourceRef.current, outcome, ...correctionStatsRef.current }),
});
}
};
settleFrameRef.current = requestAnimationFrame(step);
}, [captureAnchorAt]);
}, [captureAnchorAt, scrollerRef]);

useEffect(() => () => {
if (settleFrameRef.current !== null) {
Expand Down
Loading