Skip to content
Open
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
40 changes: 40 additions & 0 deletions apps/web/src/components/chat/timelineScrollAnchoring.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,46 @@ describe("timelineContentOverflowsViewport", () => {
),
).toBe(false);
});

it("counts an unmeasured last row past the viewport as overflowing", () => {
// A thread reopened above its end: the tail has an estimated position but no size yet.
const unmeasuredTail = [200, Number.NaN];
expect(
timelineContentOverflowsViewport(
buildState({ positions: [0, 900], sizes: unmeasuredTail }),
inset,
),
).toBe(true);
expect(
timelineContentOverflowsViewport(buildState({ positions: [0, 900], sizes: [200] }), inset),
).toBe(true);
// A short thread keeps fitting until its last row is measured.
expect(
timelineContentOverflowsViewport(
buildState({ positions: [0, 200], sizes: unmeasuredTail }),
inset,
),
).toBe(false);
expect(
timelineContentOverflowsViewport(
buildState({ positions: [0, Number.NaN], sizes: unmeasuredTail }),
inset,
),
).toBe(false);
});

it("keeps anchored-turn metrics unknown until the last row is measured", () => {
const state = buildState({ positions: [0, 900], sizes: [200] });
expect(timelineContentOverflowsViewport(state, inset)).toBe(true);
expect(
getAnchoredTurnMetrics({
state,
anchorIndex: 0,
composerOverlayHeight: 100,
anchorOffset: 24,
}),
).toBeNull();
});
});

describe("timeline scroll anchoring", () => {
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/components/chat/timelineScrollAnchoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,10 @@ export function getRowBottom(state: TimelineListMeasurementState, index: number)
/**
* Whether the timeline's real rows extend past the viewport left above the
* composer. The list's own content length includes the composer inset
* spacer, so this measures from the last row instead. Unknown row geometry
* or an unmeasured viewport counts as fitting.
* spacer, so this measures from the last row instead. A last row that is
* positioned but not yet measured counts as at least 1px tall, so a thread
* reopened above its unrendered end still overflows. Otherwise unknown row
* geometry or an unmeasured viewport counts as fitting.
*/
export function timelineContentOverflowsViewport(
state: TimelineListMeasurementState | undefined,
Expand All @@ -91,7 +93,12 @@ export function timelineContentOverflowsViewport(
if (typeof scrollLength !== "number" || !Number.isFinite(scrollLength) || scrollLength <= 0) {
return false;
}
const lastBottom = getRowBottom(state, state.data.length - 1);
const lastIndex = state.data.length - 1;
// Not in getRowBottom: anchored-turn metrics must stay unknown until the row is measured.
const lastTop = state.positionAtIndex(lastIndex);
const lastBottom =
getRowBottom(state, lastIndex) ??
(typeof lastTop === "number" && Number.isFinite(lastTop) ? lastTop + 1 : null);
if (lastBottom === null) {
return false;
}
Expand Down
Loading