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
69 changes: 69 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,75 @@ describe("MessagesTimeline", () => {
},
);

it("remembers the median row size of the thread it leaves", async () => {
const { ChatCanvasContext } = await import("./ChatCanvasContext");
const { readTimelinePosition, rememberTimelinePosition } =
await import("./timelineScrollAnchoring");
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
disconnect() {}
},
);
const props = { ...buildProps(), routeThreadKey: "environment-local:measured-departure" };
const entries = ["short", "code-block", "medium"].map((id) => {
const entry = buildUserTimelineEntry(id);
return { ...entry, id, message: { ...entry.message, id: MessageId.make(id) } };
});
const sizes = new Map([
["short", 60],
["code-block", 3000],
["medium", 80],
["row-from-another-thread", 20],
]);
props.listRef.current = {
getScrollableNode: () => null,
getState: () => ({ sizes }),
} as unknown as LegendListRef;
rememberTimelinePosition(props.routeThreadKey, {
rowId: "medium",
offsetWithinRow: 0,
scrollOffset: 0,
atEnd: true,
});
const canvas = {
container: { width: 801, height: 900 },
registerTimeline: () => {},
} as unknown as NonNullable<React.ContextType<typeof ChatCanvasContext>>;
const element = {
addEventListener: () => {},
removeEventListener: () => {},
getBoundingClientRect: () => ({ top: 0, width: 800.5, height: 900 }),
querySelector: () => null,
ownerDocument: { addEventListener: () => {}, removeEventListener: () => {} },
};
let renderer: ReactTestRenderer | undefined;
try {
await act(() => {
renderer = create(
<ChatCanvasContext value={canvas}>
<MessagesTimeline {...props} timelineEntries={entries} />
</ChatCanvasContext>,
{
createNodeMock: (node) =>
(node.props as { onScrollCapture?: unknown }).onScrollCapture ? element : null,
},
);
});
await act(() => renderer?.unmount());
renderer = undefined;

expect(readTimelinePosition(props.routeThreadKey)?.itemSize).toEqual({
viewportWidth: 801,
median: 80,
});
} finally {
await act(() => renderer?.unmount());
}
});

it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const markup = renderToStaticMarkup(
Expand Down
55 changes: 47 additions & 8 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,9 @@ import { useFileContextMenuHandler } from "../../fileContextMenu";
import { useProject, useThreadShell } from "../../state/entities";
import {
CHAT_TIMELINE_ANCHOR_OFFSET,
medianMeasuredRowSize,
readTimelinePosition,
rememberTimelineItemSize,
rememberTimelinePosition,
timelineContentOverflowsViewport,
} from "./timelineScrollAnchoring";
Expand Down Expand Up @@ -630,11 +632,25 @@ const ConversationTimeline = memo(function ConversationTimeline({
historyControls,
loadEarlier = null,
}: MessagesTimelineProps) {
const canvas = useChatCanvas();
const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
null,
);
const listIdentityKey = displayThreadKey ?? routeThreadKey;
const rememberedPosition = useMemo(
() => readTimelinePosition(listIdentityKey),
[listIdentityKey],
);
const rememberedItemSize = rememberedPosition?.itemSize;
const timelineReady =
!rememberedItemSize ||
timelineViewportElement !== null ||
Comment thread
flamboh marked this conversation as resolved.
!canvas ||
canvas.container.width > 0;
const estimatedItemSize =
rememberedItemSize && rememberedItemSize.viewportWidth === canvas?.container.width
? rememberedItemSize.median
: 90;
const [expandedRunIds, setExpandedRunIds] = useState<ReadonlySet<RunId>>(
() => rememberedPosition?.disclosures?.runs ?? new Set(),
);
Expand Down Expand Up @@ -932,7 +948,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
[restoreRowIndex, restoringThreadPosition],
);
useLayoutEffect(() => {
if (!restoringThreadPosition || rows.length === 0) return;
if (!timelineReady || !restoringThreadPosition || rows.length === 0) return;
const list = listRef.current;
if (!list) return;
if (citationRequest !== null) {
Expand Down Expand Up @@ -1044,11 +1060,9 @@ const ConversationTimeline = memo(function ConversationTimeline({
rememberedPosition,
restoringThreadPosition,
rows,
timelineReady,
]);

const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
null,
);
// Re-measure the minimap gutter when the chat column changes width without a viewport resize.
const chatWidth = useClientSettings((settings) => settings.chatWidth);
const {
Expand Down Expand Up @@ -1172,8 +1186,14 @@ const ConversationTimeline = memo(function ConversationTimeline({
// it is dropped first.
useLayoutEffect(() => {
cancelContentOverflowFrame();
onContentOverflowChange?.(measureContentOverflow());
}, [cancelContentOverflowFrame, measureContentOverflow, onContentOverflowChange, rows.length]);
if (timelineReady) onContentOverflowChange?.(measureContentOverflow());
}, [
cancelContentOverflowFrame,
measureContentOverflow,
onContentOverflowChange,
rows.length,
timelineReady,
]);

const handleScroll = useCallback(() => {
const state = listRef.current?.getState?.();
Expand Down Expand Up @@ -1262,6 +1282,24 @@ const ConversationTimeline = memo(function ConversationTimeline({
return () => cancelAnimationFrame(frame);
}, [handleScroll, rows.length]);

const canvasWidth = canvas?.container.width;
const departingTimelineRef = useRef({ rows, canvasWidth });
useLayoutEffect(() => {
departingTimelineRef.current = { rows, canvasWidth };
});
useLayoutEffect(() => {
const list = listRef.current;
if (!timelineViewportElement || !list) return;
return () => {
const { rows: departingRows, canvasWidth: viewportWidth } = departingTimelineRef.current;
const sizes = list.getState?.()?.sizes;
const median = sizes && medianMeasuredRowSize(departingRows, sizes);
if (viewportWidth && median !== undefined) {
rememberTimelineItemSize(listIdentityKey, { viewportWidth, median });
}
};
}, [listIdentityKey, listRef, timelineViewportElement]);

useEffect(() => {
if (!timelineViewportElement) {
return;
Expand Down Expand Up @@ -1436,7 +1474,6 @@ const ConversationTimeline = memo(function ConversationTimeline({
);
}, [historyControls, onOpenThread, parentThreadLink, topFadeEnabled]);

const canvas = useChatCanvas();
const registerTimeline = canvas?.registerTimeline;
const setTimelineList = useCallback(
(list: LegendListRef | null) => {
Expand Down Expand Up @@ -1561,6 +1598,8 @@ const ConversationTimeline = memo(function ConversationTimeline({
);
}

if (!timelineReady) return null;

return (
<MarkdownFindContext value={findActive}>
<TimelineRowCtx value={sharedState}>
Expand All @@ -1584,7 +1623,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
keyExtractor={keyExtractor}
getItemType={getItemType}
renderItem={renderItem}
estimatedItemSize={90}
estimatedItemSize={estimatedItemSize}
initialScrollAtEnd={
!findActive && citationRequest === null && rememberedPosition?.atEnd !== false
}
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/components/chat/timelineScrollAnchoring.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { MessageId, RunId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import {
medianMeasuredRowSize,
observeTimelineRun,
getAnchoredTurnMetrics,
getRowBottom,
Expand Down Expand Up @@ -282,3 +283,32 @@ describe("remembered timeline positions", () => {
expect(readTimelinePosition("scroll-test-a:thread-1")).toEqual(following);
});
});

describe("medianMeasuredRowSize", () => {
it("ignores measurements the retained list kept from previously visited threads", () => {
const compactRows = Array.from({ length: 255 }, (_, index) => ({ id: `fold-${index}` }));
const longRows = Array.from({ length: 160 }, (_, index) => ({ id: `assistant-${index}` }));
const measuredSizes = new Map([
...compactRows.map((row) => [row.id, 30] as const),
...longRows.slice(0, 17).map((row) => [row.id, 300] as const),
]);
expect(medianMeasuredRowSize(longRows, measuredSizes)).toBe(300);
expect(medianMeasuredRowSize(compactRows, measuredSizes)).toBe(30);
});

it("is not inflated by a few very tall rows", () => {
const rows = ["a", "b", "c", "d", "e"].map((id) => ({ id }));
const measuredSizes = new Map([
["a", 60],
["b", 3000],
["c", 64],
["d", 2400],
["e", 58],
]);
expect(medianMeasuredRowSize(rows, measuredSizes)).toBe(64);
});

it("reports nothing until a current row has been measured", () => {
expect(medianMeasuredRowSize([{ id: "unmeasured" }], new Map([["other", 40]]))).toBe(undefined);
});
});
22 changes: 22 additions & 0 deletions apps/web/src/components/chat/timelineScrollAnchoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ export interface RememberedTimelinePosition {
readonly offsetWithinRow: number;
readonly scrollOffset: number;
readonly atEnd: boolean;
readonly itemSize?: {
readonly viewportWidth: number;
readonly median: number;
};
readonly disclosures?: {
readonly runs: ReadonlySet<RunId>;
readonly workGroups: ReadonlySet<string>;
Expand All @@ -158,6 +162,16 @@ export interface RememberedTimelinePosition {
};
}

export function medianMeasuredRowSize(
rows: ReadonlyArray<{ readonly id: string }>,
measuredSizes: ReadonlyMap<string, number>,
) {
const sizes = rows
.flatMap((row) => measuredSizes.get(row.id) ?? [])
.toSorted((left, right) => left - right);
return sizes[Math.floor(sizes.length / 2)];
}

// Scoped thread keys keep separate environments independent. Bound the session cache.
const rememberedTimelinePositions = new Map<string, RememberedTimelinePosition>();

Expand All @@ -173,3 +187,11 @@ export function rememberTimelinePosition(threadKey: string, position: Remembered
if (oldest !== undefined) rememberedTimelinePositions.delete(oldest);
}
}

export function rememberTimelineItemSize(
threadKey: string,
itemSize: NonNullable<RememberedTimelinePosition["itemSize"]>,
) {
const position = rememberedTimelinePositions.get(threadKey);
if (position) rememberedTimelinePositions.set(threadKey, { ...position, itemSize });
}
Loading