diff --git a/AGENTS.md b/AGENTS.md index d1e994e065..9ec5ba9418 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/apps/code/snapshots.yml b/apps/code/snapshots.yml index 751e81b16b..b98d781990 100644 --- a/apps/code/snapshots.yml +++ b/apps/code/snapshots.yml @@ -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: diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index 9f6ab47eab..b19331cfa0 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -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, @@ -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(); + 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 @@ -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({ 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); }, }); @@ -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( @@ -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) => { @@ -451,6 +473,23 @@ export function ConversationView({ [renderItem, sessionViewActions], ); + const renderTurn = useCallback( + (turn: ConversationTurn) => ( +
+ {turn.rows.map((row) => ( +
+ {renderRow(row)} +
+ ))} +
+ ), + [renderRow], + ); + const footer = (
@@ -509,15 +548,15 @@ export function ConversationView({ /> - + 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} diff --git a/packages/ui/src/features/sessions/components/VirtualizedList.test.tsx b/packages/ui/src/features/sessions/components/VirtualizedList.test.tsx new file mode 100644 index 0000000000..802eb60f97 --- /dev/null +++ b/packages/ui/src/features/sessions/components/VirtualizedList.test.tsx @@ -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( + item.id} + renderItem={(item) =>
turn
} + />, + ); +} + +describe("VirtualizedList", () => { + beforeEach(() => { + mocks.setTotalSize(80); + vi.clearAllMocks(); + vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation( + function (this: HTMLElement) { + const value = + this.querySelector("[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( + item.id} + renderItem={(item) =>
turn
} + />, + ); + + 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( + item.id} + renderItem={(item) =>
turn
} + />, + ); + + expect(mocks.scrollToEnd).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/features/sessions/components/VirtualizedList.tsx b/packages/ui/src/features/sessions/components/VirtualizedList.tsx index ef5ae0e174..664dad7774 100644 --- a/packages/ui/src/features/sessions/components/VirtualizedList.tsx +++ b/packages/ui/src/features/sessions/components/VirtualizedList.tsx @@ -4,6 +4,7 @@ import { type CSSProperties, forwardRef, type ReactNode, + type TouchEvent, useCallback, useEffect, useImperativeHandle, @@ -11,6 +12,7 @@ import { useMemo, useRef, useState, + type WheelEvent, } from "react"; interface VirtualizedListProps { @@ -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. @@ -67,6 +66,7 @@ function VirtualizedListInner( 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(null); const onScrollStateChangeRef = useRef(onScrollStateChange); @@ -115,6 +115,16 @@ function VirtualizedListInner( }, }); + 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); @@ -234,6 +244,37 @@ function VirtualizedListInner( onScrollStateChangeRef.current?.(isAtBottomRef.current); }, [virtualizer]); + const handleWheelCapture = useCallback( + (event: WheelEvent) => { + if (event.deltaY < 0) isAtBottomRef.current = false; + }, + [], + ); + + const handleTouchStartCapture = useCallback( + (event: TouchEvent) => { + const touch = event.touches[0]; + touchStartRef.current = touch + ? { x: touch.clientX, y: touch.clientY } + : null; + }, + [], + ); + + const handleTouchMoveCapture = useCallback( + (event: TouchEvent) => { + 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(() => { @@ -254,6 +295,9 @@ function VirtualizedListInner(
@@ -272,7 +316,7 @@ function VirtualizedListInner( return (
{ + it("keeps each prompt and its response in one virtual row", () => { + const result = groupRowsIntoTurns([ + row(userMessage("user-1")), + row(cancelled("reply-1")), + row(userMessage("user-2")), + row(cancelled("reply-2")), + ]); + + expect( + result.turns.map((turn) => turn.rows.map((item) => item.id)), + ).toEqual([ + ["user-1", "reply-1"], + ["user-2", "reply-2"], + ]); + }); + + it("maps source rows to their virtual turn", () => { + const result = groupRowsIntoTurns([ + row(userMessage("user-1")), + row(cancelled("reply-1")), + row(userMessage("user-2")), + ]); + + expect(result.rowToTurnIndex).toEqual([0, 0, 1]); + }); + + it.each(["git_action", "skill_button_action"] as const)( + "starts a turn for %s", + (type) => { + const result = groupRowsIntoTurns([ + row(userMessage("user-1")), + row(cancelled("reply-1")), + row(userAction("action-1", type)), + row(cancelled("reply-2")), + ]); + + expect( + result.turns.map((turn) => turn.rows.map((item) => item.id)), + ).toEqual([ + ["user-1", "reply-1"], + ["action-1", "reply-2"], + ]); + }, + ); +}); diff --git a/packages/ui/src/features/sessions/components/groupConversationTurns.ts b/packages/ui/src/features/sessions/components/groupConversationTurns.ts new file mode 100644 index 0000000000..f8875e8b32 --- /dev/null +++ b/packages/ui/src/features/sessions/components/groupConversationTurns.ts @@ -0,0 +1,30 @@ +import { isUserInitiatedConversationItem } from "@posthog/ui/features/sessions/components/isUserInitiatedConversationItem"; +import type { ThreadRow } from "@posthog/ui/features/sessions/components/new-thread/buildThreadGroups"; + +export interface ConversationTurn { + id: string; + rows: ThreadRow[]; +} + +function startsConversationTurn(row: ThreadRow): boolean { + return row.kind === "item" && isUserInitiatedConversationItem(row.item); +} + +export function groupRowsIntoTurns(rows: ThreadRow[]): { + turns: ConversationTurn[]; + rowToTurnIndex: number[]; +} { + const turns: ConversationTurn[] = []; + const rowToTurnIndex: number[] = []; + + for (const row of rows) { + if (turns.length === 0 || startsConversationTurn(row)) { + turns.push({ id: `turn:${row.id}`, rows: [] }); + } + const turnIndex = turns.length - 1; + turns[turnIndex].rows.push(row); + rowToTurnIndex.push(turnIndex); + } + + return { turns, rowToTurnIndex }; +} diff --git a/packages/ui/src/features/sessions/components/isUserInitiatedConversationItem.ts b/packages/ui/src/features/sessions/components/isUserInitiatedConversationItem.ts new file mode 100644 index 0000000000..cf4a7d9ae8 --- /dev/null +++ b/packages/ui/src/features/sessions/components/isUserInitiatedConversationItem.ts @@ -0,0 +1,9 @@ +export function isUserInitiatedConversationItem(item: { + type: string; +}): boolean { + return ( + item.type === "user_message" || + item.type === "git_action" || + item.type === "skill_button_action" + ); +} diff --git a/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.ts b/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.ts index 73d8586e86..ea03759872 100644 --- a/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.ts +++ b/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.ts @@ -1,6 +1,7 @@ import type { Icon } from "@phosphor-icons/react"; import { readAgentToolName, readMcpToolDescriptor } from "@posthog/shared"; import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { isUserInitiatedConversationItem } from "@posthog/ui/features/sessions/components/isUserInitiatedConversationItem"; import { buildDoneLabel, type CollapseMode, @@ -323,14 +324,12 @@ export function buildThreadGroups( }; for (const item of items) { + if (isUserInitiatedConversationItem(item)) { + flush(); + pushItemRow(item); + continue; + } switch (item.type) { - case "user_message": - case "git_action": - case "skill_button_action": { - flush(); - pushItemRow(item); - break; - } case "session_update": { if (isGroupableItem(item)) { buffer.push(item);