Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ See [docs/conventions.md](./docs/conventions.md).
- Colocate tests as `.test.ts` or `.test.tsx`.
- Put E2E tests in `tests/e2e/`.
- Drive and screenshot the real running app (live data) with agent-browser over CDP `:9222`: run `pnpm app:cdp` or use the `test-electron-app` skill.
- Treat virtualized-list overscan as a performance setting, not a scroll-correctness fix; preserve the visible anchor when measured row heights change.
- After touching `@posthog/platform`, rebuild or typecheck its `dist/`.
- After touching `packages/core`, run `biome lint packages/core` and verify zero `noRestrictedImports`.

Expand Down
20 changes: 10 additions & 10 deletions apps/code/snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -317,33 +317,33 @@ snapshots:
features-messageeditor-promptinput--with-multiple-chips--light:
hash: v1.k4693efd2.f844653d745a308116b94546ec6605028e90246b6aa9fa640a1b8f9c579193e1.PkuP-7A0MOZk7V3mpsJDx4pwJA0VoIzMnp4BIgDQ45U
features-sessions-conversationview--all-tool-calls--dark:
hash: v1.k4693efd2.602ee87d6d7a745d4b09d9e49bd12a70e52299b78ca97a48b5ca73a01532d801.Imd_IYgKXb6kcfjhRfBhmc4-AoPoKcfg1QRNXB4NL_I
hash: v1.k4693efd2.f4949811298614ff01aef988b12418ce3b99f1c9ce2428b81cb9d1ca201afa83.2sjUZgIeLsL94--VLigefyWa7Dt-CMgn6e3kZBwIbbs
features-sessions-conversationview--all-tool-calls--light:
hash: v1.k4693efd2.1636dfc3ee0dd9d28065166f2fff0193f2d499692c74749d3afa7585aae898b5.7Ez0SM2DUXepp6Q8SgUyX3zVbdbf2kvglPqJsEc-eB4
hash: v1.k4693efd2.1b1de752527fdac9d07e0e4fadd22bebb6cedb9afe70b2f69cdc1f8efc1426cf.a9nv4k0LOdXfShX-O3KeQ8_fZZimICJhBfbFYSLEHvA
features-sessions-conversationview--empty--dark:
hash: v1.k4693efd2.f7b3396b9c83b952221044d2058df7ddf985fab959dba2c691c321ca528ca2af.0rJM8GT1SXRzo0MFo3OPgV6oL4d8sLPm9vz18-na5iw
features-sessions-conversationview--empty--light:
hash: v1.k4693efd2.8fe9c532387523d9642ec7e9265d88435137aa739cb3763a0024335997697b12.ZtBmVlV92Opv5y4NHYDTPt-xvgcel_Z8JMfgnZJ9ZOE
features-sessions-conversationview--long-conversation--dark:
hash: v1.k4693efd2.34cfdd21ad9f643d7cf82b618f0e8e5c25856bc9675f816159bb0c4e4f67fa23.wrleGZzGujLjrbU_CMCGg30OLjDf63_XJlJ-7QJZS3A
hash: v1.k4693efd2.07392ebacdd12e07e700f11cccf04c3be2c362cb7e2f41976e26923e59b27160.jOla4JdWmbahmhpjrr0w8tCip212FVig7-XeVlSNjjg
features-sessions-conversationview--long-conversation--light:
hash: v1.k4693efd2.46629a82ba4239edf9d576f46a9696b33e7f63115aa773ada55af910d1cfec0c.c_Dh3-59F1Y3ehdwhro7_ImEILRdDK_42mmHui-shSY
hash: v1.k4693efd2.e012849e1a5de1f56373b5e9160d2619bd337c982144284eb96a126f7a304416.YYx7K2lNsepUTPHfH3ySLJFCKB-n_4i33mz1rC5L4Q8
features-sessions-conversationview--markdown-debug--dark:
hash: v1.k4693efd2.a1c2f118c135f553d72f1ed783782a8d9d37de47280c94a01f0aeed764f64d33.kZrv6ZRnKbRkFhgm_QYWvlqjC7VQbBlWCOnfi5pxZyY
hash: v1.k4693efd2.3d1d3f2223d8610891f2e9a7f24ad99e9a3b368f64148df4b7d8beb4bf816a0b.ujd-hdWnmS3f85tkT4ip4mdbYWaWj0zhJSjrBdwrMrc
features-sessions-conversationview--markdown-debug--light:
hash: v1.k4693efd2.75d098556db1af478095a77c26d7672a17a1651b8630341bd987607254d5cba1.wqcro6zc8l95cDgebzeZt-h5HIxnlwQKCEHiNrlP1v8
hash: v1.k4693efd2.bfef475de0f4bbf6e8d75249764169782b55cd945b619a9c93e961ca8e65ce33.wjN3li8QUanEMSlbJO1y0k3VlGmLa_7z12rRqojY7wM
features-sessions-conversationview--markdown-showcase--dark:
hash: v1.k4693efd2.b9901edc583c07111e8f515c557eeb547d767b2f29d61fa9a50523fa5275f6ec.KXz4KANV2VPPLFzt7_A0UTivT__wivJLBJSgJQfy2Es
hash: v1.k4693efd2.1f48560e8958d45cb99858a5d46055a8b53ed341ab19ef3afd0812686c6459cd.8QjEBONE-KR7xm0-dYJsf4I_ftvUzEz5DW5KHSEALIk
features-sessions-conversationview--markdown-showcase--light:
hash: v1.k4693efd2.9ad5d2969e031ce98aeff5e9ff527118611ac6aaa73a83424d60acf777a32505.eNCuFlo2qByk8LARAV4cRqKE4UsIiknv920p6AyyfpQ
hash: v1.k4693efd2.999acf075dc1f41dee05f93a86f514a41e27e92f22ddbf441cfe204488d6aa71.163W210QTjmx3S8ZUO76-r8Qud5M15FD-78ZQFTd94I
features-sessions-conversationview--single-turn--dark:
hash: v1.k4693efd2.26b602bfb096c1b19ed2e9ed8d363d3c8c77a21828df32f71a83ff053b759a96.tpvBFAGHOcxqIfCbU49YjzSe0GwuDreM8fFCWhlDs58
features-sessions-conversationview--single-turn--light:
hash: v1.k4693efd2.20569531952a9f448601f8d0a0ccf5ae79341b904b4f79987142a2574c95e404.8HpLPHXhZpEX9kA5Hn78WskRYFAUev8X7SLPeWGSEbA
features-sessions-conversationview--with-pending-prompt--dark:
hash: v1.k4693efd2.602ee87d6d7a745d4b09d9e49bd12a70e52299b78ca97a48b5ca73a01532d801.F_1XKDzCxzMm_opEt9FvzczBb_2n-vyvOyGFXHGJmTk
hash: v1.k4693efd2.f4949811298614ff01aef988b12418ce3b99f1c9ce2428b81cb9d1ca201afa83.tERhjA4UEzpFY061lsvkohwSyDIzQphlOOLb3sv73_g
features-sessions-conversationview--with-pending-prompt--light:
hash: v1.k4693efd2.1636dfc3ee0dd9d28065166f2fff0193f2d499692c74749d3afa7585aae898b5.YxTPa4CsqI2YuGLUdck8340ITZQrpna_C6z-SgII4Ws
hash: v1.k4693efd2.1b1de752527fdac9d07e0e4fadd22bebb6cedb9afe70b2f69cdc1f8efc1426cf.CBx-n_SpJHiKhihAucVVc0AraAFqSQECVTEGsHE4jL4
features-sessions-toolcallblock--create-new-file--dark:
hash: v1.k4693efd2.8847f96037682460aba47f0e0f5113037918235fc98596933f2d1d06fd6f8f92.SHetexoVOtozsZGZQMcVbmpBlbk7M5D5Q0R_TJvPnyc
features-sessions-toolcallblock--create-new-file--light:
Expand Down
69 changes: 54 additions & 15 deletions packages/ui/src/features/sessions/components/ConversationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import { THREAD_HOTKEY_OPTIONS } from "@posthog/ui/features/sessions/components/
import { usePromptRecallSource } from "@posthog/ui/features/sessions/components/chat-thread/usePromptRecallSource";
import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage";
import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult";
import {
type ConversationTurn,
groupRowsIntoTurns,
} from "@posthog/ui/features/sessions/components/groupConversationTurns";
import { mergeConversationItems } from "@posthog/ui/features/sessions/components/mergeConversationItems";
import type {
ThreadGrouping,
Expand Down Expand Up @@ -212,7 +216,25 @@ export function ConversationView({
);
const threadRows = grouping.rows;
const rowKeepMounted = grouping.keepMounted;
const itemIdToRowIndex = grouping.idToRowIndex;
const { turns, rowToTurnIndex } = useMemo(
() => groupRowsIntoTurns(threadRows),
[threadRows],
);
const turnKeepMounted = useMemo(
() => [...new Set(rowKeepMounted.map((index) => rowToTurnIndex[index]))],
[rowKeepMounted, rowToTurnIndex],
);
const itemIdToTurnIndex = useMemo(() => {
const result = new Map<string, number>();
for (const [id, rowIndex] of grouping.idToRowIndex) {
const turnIndex = rowToTurnIndex[rowIndex];
if (turnIndex === undefined) {
throw new Error(`Missing turn for conversation row ${rowIndex}`);
}
result.set(id, turnIndex);
}
return result;
}, [grouping.idToRowIndex, rowToTurnIndex]);

// Changing the global mode wipes ephemeral per-chip overrides.
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally keyed on collapseMode only
Expand All @@ -226,15 +248,15 @@ export function ConversationView({
// since grouped rows != items.
const itemsRef = useRef(items);
itemsRef.current = items;
const itemIdToRowIndexRef = useRef(itemIdToRowIndex);
itemIdToRowIndexRef.current = itemIdToRowIndex;
const itemIdToTurnIndexRef = useRef(itemIdToTurnIndex);
itemIdToTurnIndexRef.current = itemIdToTurnIndex;
const searchListRef = useRef<VirtualizedListHandle>({
scrollToBottom: () => listRef.current?.scrollToBottom(),
scrollToIndex: (index: number) => {
const id = itemsRef.current[index]?.id;
const rowIdx =
id != null ? itemIdToRowIndexRef.current.get(id) : undefined;
listRef.current?.scrollToIndex(rowIdx ?? index);
const turnIndex =
id != null ? itemIdToTurnIndexRef.current.get(id) : undefined;
listRef.current?.scrollToIndex(turnIndex ?? index);
},
});

Expand Down Expand Up @@ -265,8 +287,8 @@ export function ConversationView({
// Grouped rows != items, so scroll by the row the message landed in (same
// mapping search uses), falling back to the raw item index.
const scrollToUserMessage = useCallback((id: string, itemIndex: number) => {
const rowIndex = itemIdToRowIndexRef.current.get(id) ?? itemIndex;
listRef.current?.scrollToIndex(rowIndex);
const turnIndex = itemIdToTurnIndexRef.current.get(id) ?? itemIndex;
listRef.current?.scrollToIndex(turnIndex);
}, []);

const handleNavigateMessage = useCallback(
Expand Down Expand Up @@ -409,7 +431,7 @@ export function ConversationView({
],
);

const getRowKey = useCallback((row: ThreadRow) => row.id, []);
const getTurnKey = useCallback((turn: ConversationTurn) => turn.id, []);

const renderRow = useCallback(
(row: ThreadRow) => {
Expand Down Expand Up @@ -451,6 +473,23 @@ export function ConversationView({
[renderItem, sessionViewActions],
);

const renderTurn = useCallback(
(turn: ConversationTurn) => (
<div>
{turn.rows.map((row) => (
<div
key={row.id}
className="py-1.5"
data-conversation-item-id={row.id}
>
{renderRow(row)}
</div>
))}
</div>
),
[renderRow],
);

const footer = (
<div className={compact ? "pb-1" : "pb-16"}>
<CloudArtifactDownloads taskId={taskId} task={task} />
Expand Down Expand Up @@ -509,15 +548,15 @@ export function ConversationView({
/>

<SessionTaskIdProvider taskId={taskId}>
<VirtualizedList<ThreadRow>
<VirtualizedList<ConversationTurn>
ref={listRef}
items={threadRows}
getItemKey={getRowKey}
renderItem={renderRow}
items={turns}
getItemKey={getTurnKey}
renderItem={renderTurn}
onScrollStateChange={handleScrollStateChange}
keepMounted={rowKeepMounted}
keepMounted={turnKeepMounted}
className="absolute inset-0 bg-background"
itemClassName="mx-auto px-2 py-1.5"
itemClassName="mx-auto px-2"
itemStyle={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }}
footer={footer}
scrollX={scrollX}
Expand Down
112 changes: 112 additions & 0 deletions packages/ui/src/features/sessions/components/VirtualizedList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { fireEvent, render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { VirtualizedList } from "./VirtualizedList";

const mocks = vi.hoisted(() => {
let totalSize = 80;
const values = {
getTotalSize: () => totalSize,
setTotalSize: (value: number) => {
totalSize = value;
},
isAtEnd: vi.fn(() => true),
measureElement: vi.fn(),
resizeItem: vi.fn(),
scrollToEnd: vi.fn(),
scrollToIndex: vi.fn(),
};
return {
...values,
virtualizer: {
getTotalSize: values.getTotalSize,
getVirtualItems: () => [{ index: 0, key: "turn-1", start: 0 }],
isAtEnd: values.isAtEnd,
measureElement: values.measureElement,
resizeItem: values.resizeItem,
scrollToEnd: values.scrollToEnd,
scrollToIndex: values.scrollToIndex,
},
};
});

vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: () => mocks.virtualizer,
}));

function renderList() {
return render(
<VirtualizedList
items={[{ id: "turn-1", height: 640 }]}
getItemKey={(item) => item.id}
renderItem={(item) => <div data-height={item.height}>turn</div>}
/>,
);
}

describe("VirtualizedList", () => {
beforeEach(() => {
mocks.setTotalSize(80);
vi.clearAllMocks();
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(
function (this: HTMLElement) {
const value =
this.querySelector<HTMLElement>("[data-height]")?.dataset.height;
return value ? Number(value) : 0;
},
);
});

it("records a variable-height turn when it mounts", () => {
renderList();

expect(mocks.resizeItem).toHaveBeenCalledWith(0, 640);
});

it("keeps following after a horizontal touch gesture", () => {
const view = renderList();
const viewport = view.container.firstElementChild?.firstElementChild;
expect(viewport).toBeInstanceOf(HTMLElement);
mocks.scrollToEnd.mockClear();

fireEvent.touchStart(viewport as Element, {
touches: [{ clientX: 10, clientY: 10 }],
});
fireEvent.touchMove(viewport as Element, {
touches: [{ clientX: 40, clientY: 12 }],
});
mocks.setTotalSize(640);
view.rerender(
<VirtualizedList
items={[{ id: "turn-1", height: 640 }]}
getItemKey={(item) => item.id}
renderItem={(item) => <div data-height={item.height}>turn</div>}
/>,
);

expect(mocks.scrollToEnd).toHaveBeenCalledOnce();
});

it("stops following before a touch gesture scrolls toward older turns", () => {
const view = renderList();
const viewport = view.container.firstElementChild?.firstElementChild;
expect(viewport).toBeInstanceOf(HTMLElement);
mocks.scrollToEnd.mockClear();

fireEvent.touchStart(viewport as Element, {
touches: [{ clientX: 10, clientY: 10 }],
});
fireEvent.touchMove(viewport as Element, {
touches: [{ clientX: 12, clientY: 40 }],
});
mocks.setTotalSize(640);
view.rerender(
<VirtualizedList
items={[{ id: "turn-1", height: 640 }]}
getItemKey={(item) => item.id}
renderItem={(item) => <div data-height={item.height}>turn</div>}
/>,
);

expect(mocks.scrollToEnd).not.toHaveBeenCalled();
});
});
52 changes: 48 additions & 4 deletions packages/ui/src/features/sessions/components/VirtualizedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import {
type CSSProperties,
forwardRef,
type ReactNode,
type TouchEvent,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
type WheelEvent,
} from "react";

interface VirtualizedListProps<T> {
Expand Down Expand Up @@ -39,9 +41,6 @@ export interface VirtualizedListHandle {

const AT_BOTTOM_THRESHOLD = 50;
const ESTIMATED_ROW_SIZE = 80;
// Render rows well ahead so tall, async rows (markdown, code, diffs) measure and
// paint off-screen instead of shifting and stuttering as they enter view. 12
// erases the scroll-up shift empirically; higher only adds DOM cost.
const OVERSCAN = 12;
// A real upward drift, not a 1-frame measure transient: the DOM bottom sits
// this far below the viewport. Well above any single append's measure gap.
Expand All @@ -67,6 +66,7 @@ function VirtualizedListInner<T>(
const initializedRef = useRef(false);
const isAtBottomRef = useRef(true);
const lastScrollTopRef = useRef(0);
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
const settlingRef = useRef(false);
const settleRafRef = useRef<number | null>(null);
const onScrollStateChangeRef = useRef(onScrollStateChange);
Expand Down Expand Up @@ -115,6 +115,16 @@ function VirtualizedListInner<T>(
},
});

const measureElementImmediately = useCallback(
(node: HTMLDivElement | null) => {
virtualizer.measureElement(node);
if (!node) return;
const index = Number(node.dataset.index);
virtualizer.resizeItem(index, node.offsetHeight);
},
[virtualizer],
);

const settleAtEnd = useCallback(() => {
if (settleRafRef.current !== null) {
cancelAnimationFrame(settleRafRef.current);
Expand Down Expand Up @@ -234,6 +244,37 @@ function VirtualizedListInner<T>(
onScrollStateChangeRef.current?.(isAtBottomRef.current);
}, [virtualizer]);

const handleWheelCapture = useCallback(
(event: WheelEvent<HTMLDivElement>) => {
if (event.deltaY < 0) isAtBottomRef.current = false;
},
[],
);

const handleTouchStartCapture = useCallback(
(event: TouchEvent<HTMLDivElement>) => {
const touch = event.touches[0];
touchStartRef.current = touch
? { x: touch.clientX, y: touch.clientY }
: null;
},
[],
);

const handleTouchMoveCapture = useCallback(
(event: TouchEvent<HTMLDivElement>) => {
const start = touchStartRef.current;
const touch = event.touches[0];
if (!start || !touch) return;
const deltaX = touch.clientX - start.x;
const deltaY = touch.clientY - start.y;
if (deltaY > 0 && Math.abs(deltaY) > Math.abs(deltaX)) {
isAtBottomRef.current = false;
}
},
[],
);

const virtualItems = virtualizer.getVirtualItems();

const renderedIndices = useMemo(() => {
Expand All @@ -254,6 +295,9 @@ function VirtualizedListInner<T>(
<div
ref={parentRef}
onScroll={handleScroll}
onWheelCapture={handleWheelCapture}
onTouchStartCapture={handleTouchStartCapture}
onTouchMoveCapture={handleTouchMoveCapture}
className={`scroll-mask-8 flex-1 overflow-y-auto ${scrollX ? "overflow-x-auto" : "overflow-x-hidden"}`}
style={{ scrollbarGutter: "stable" }}
>
Expand All @@ -272,7 +316,7 @@ function VirtualizedListInner<T>(
return (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
ref={measureElementImmediately}
data-index={virtualItem.index}
style={{
position: "absolute",
Expand Down
Loading
Loading