From 5cfd33f0a0e1098c76cce2d01ac065eb85bb9d5d Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Sat, 27 Jun 2026 14:58:25 +0300 Subject: [PATCH 1/5] fix(sessions): smooth conversation scroll-up jank The transcript virtualizer seeded every row with a flat 80px estimate while real rows range from ~32px chips to 1000px diffs. A thread opens scrolled to the bottom, so only the last few rows ever get measured; the first scroll-up corrects every row above from estimate to true height at once, and tanstack skips scroll compensation during backward scroll, so the uncompensated growth shoves the viewport. Add a content-aware per-row estimate to shrink each correction, and compensate scrollTop for above-viewport resizes regardless of scroll direction so visible content stays put. Bottom-follow is unaffected. Generated-By: PostHog Code Task-Id: f1ca2a14-42ac-49de-8ccd-b1f2de1e10e0 --- .../sessions/components/ConversationView.tsx | 2 + .../sessions/components/VirtualizedList.tsx | 32 +++++++- .../new-thread/estimateThreadRow.test.ts | 58 ++++++++++++++ .../new-thread/estimateThreadRow.ts | 79 +++++++++++++++++++ 4 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.test.ts create mode 100644 packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.ts diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index d15543515..85ce3c48b 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -23,6 +23,7 @@ import type { ThreadRow, } from "@posthog/ui/features/sessions/components/new-thread/buildThreadGroups"; import type { CollapseMode } from "@posthog/ui/features/sessions/components/new-thread/conversationThreadConfig"; +import { estimateThreadRow } from "@posthog/ui/features/sessions/components/new-thread/estimateThreadRow"; import { createIncrementalThreadGrouper } from "@posthog/ui/features/sessions/components/new-thread/incrementalThreadGrouping"; import { ToolCallGroupChip } from "@posthog/ui/features/sessions/components/new-thread/ToolCallGroupChip"; import { SessionFooter } from "@posthog/ui/features/sessions/components/SessionFooter"; @@ -381,6 +382,7 @@ export function ConversationView({ ref={listRef} items={threadRows} getItemKey={getRowKey} + estimateItemSize={estimateThreadRow} renderItem={renderRow} onScrollStateChange={handleScrollStateChange} keepMounted={rowKeepMounted} diff --git a/packages/ui/src/features/sessions/components/VirtualizedList.tsx b/packages/ui/src/features/sessions/components/VirtualizedList.tsx index dab37a4e4..f6ac04ca5 100644 --- a/packages/ui/src/features/sessions/components/VirtualizedList.tsx +++ b/packages/ui/src/features/sessions/components/VirtualizedList.tsx @@ -17,6 +17,15 @@ interface VirtualizedListProps { items: T[]; renderItem: (item: T, index: number) => ReactNode; getItemKey?: (item: T, index: number) => string | number; + /** + * Pre-measurement height guess per row. Rows are measured for real once they + * mount, but until then the virtualizer lays them out at this estimate. A + * thread that opens scrolled to the bottom never mounts the rows above, so + * the first scroll-up corrects every one of them from the estimate to its + * true height at once — the closer the estimate, the smaller each correction + * and the less the content shifts. Falls back to a flat constant when unset. + */ + estimateItemSize?: (item: T, index: number) => number; className?: string; itemClassName?: string; itemStyle?: CSSProperties; @@ -49,6 +58,7 @@ function VirtualizedListInner( items, renderItem, getItemKey, + estimateItemSize, className, itemClassName, itemStyle, @@ -100,7 +110,12 @@ function VirtualizedListInner( const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, - estimateSize: () => ESTIMATED_ROW_SIZE, + estimateSize: (index) => { + const item = items[index]; + return estimateItemSize && item !== undefined + ? estimateItemSize(item, index) + : ESTIMATED_ROW_SIZE; + }, overscan: OVERSCAN, anchorTo: "end", followOnAppend: true, @@ -112,6 +127,21 @@ function VirtualizedListInner( }, }); + // Keep already-visible content from jumping when a row ABOVE the viewport + // remeasures taller/shorter than its estimate. tanstack's default skips this + // compensation while scrolling backward (to avoid fighting momentum), but + // that's exactly when a first scroll-up corrects a run of never-measured rows + // — so the uncompensated growth shoves the viewport. Compensating + // unconditionally for above-viewport resizes holds the content steady. The + // bottom-follow path (anchorTo:"end") is handled before this predicate is + // consulted, so pinning to the end is unaffected. Not in 3.17's options type, + // so set on the instance directly. + virtualizer.shouldAdjustScrollPositionOnItemSizeChange = ( + item, + _delta, + instance, + ) => item.start < (instance.scrollOffset ?? 0); + const settleAtEnd = useCallback(() => { if (settleRafRef.current !== null) { cancelAnimationFrame(settleRafRef.current); diff --git a/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.test.ts b/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.test.ts new file mode 100644 index 000000000..24097625e --- /dev/null +++ b/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.test.ts @@ -0,0 +1,58 @@ +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import type { ThreadRow } from "@posthog/ui/features/sessions/components/new-thread/buildThreadGroups"; +import { estimateThreadRow } from "@posthog/ui/features/sessions/components/new-thread/estimateThreadRow"; +import { describe, expect, it } from "vitest"; + +function userMessage(content: string): ConversationItem { + return { type: "user_message", id: "u", content, timestamp: 0 }; +} + +function itemRow(item: ConversationItem): ThreadRow { + return { kind: "item", id: item.id, item }; +} + +describe("estimateThreadRow", () => { + it("estimates a collapsed tool group as just its chip", () => { + const row: ThreadRow = { + kind: "tool_group", + id: "g", + items: [userMessage("a".repeat(2000))], + summary: {} as never, + turnComplete: true, + expanded: false, + }; + expect(estimateThreadRow(row)).toBe(44); + }); + + it("estimates an expanded group as the stack of its items", () => { + const items = [userMessage("short"), userMessage("short")]; + const collapsed: ThreadRow = { + kind: "tool_group", + id: "g", + items, + summary: {} as never, + turnComplete: true, + expanded: false, + }; + const expanded: ThreadRow = { ...collapsed, expanded: true }; + expect(estimateThreadRow(expanded)).toBeGreaterThan( + estimateThreadRow(collapsed), + ); + }); + + it("scales a message estimate with its line count", () => { + const oneLine = estimateThreadRow(itemRow(userMessage("hi"))); + const manyLines = estimateThreadRow( + itemRow(userMessage(Array(10).fill("line").join("\n"))), + ); + expect(manyLines).toBeGreaterThan(oneLine); + }); + + it("keeps chip-style rows well under the old flat 80px guess", () => { + expect( + estimateThreadRow( + itemRow({ type: "git_action", id: "g", actionType: "push" }), + ), + ).toBeLessThan(80); + }); +}); diff --git a/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.ts b/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.ts new file mode 100644 index 000000000..d7ee226b5 --- /dev/null +++ b/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.ts @@ -0,0 +1,79 @@ +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import type { ThreadRow } from "@posthog/ui/features/sessions/components/new-thread/buildThreadGroups"; + +// Pre-measurement height guesses (px) for the conversation virtualizer. These +// only need to be in the right ballpark: the row is measured for real once it +// mounts, and the estimate's only job is to keep the first scroll-up from +// jumping as never-measured rows snap to their true height. Tuned against the +// 750px content column, so a wrapped line holds ~95 chars at ~22px tall. +const CHARS_PER_LINE = 95; +const LINE_HEIGHT = 22; +// itemClassName py-1.5 (6px top + bottom) wrapping every row. +const ROW_PADDING = 12; + +function estimateTextHeight(text: string): number { + let lines = 0; + for (const segment of text.split("\n")) { + lines += Math.max(1, Math.ceil(segment.length / CHARS_PER_LINE)); + } + return ROW_PADDING + Math.max(1, lines) * LINE_HEIGHT; +} + +function estimateConversationItem(item: ConversationItem): number { + switch (item.type) { + case "user_message": { + const attachments = item.attachments?.length ? 72 : 0; + return estimateTextHeight(item.content) + attachments + 16; + } + case "git_action": + return 36; + case "skill_button_action": + return 44; + case "git_action_result": + return 64; + case "turn_cancelled": + return 40; + case "user_shell_execute": + return 64; + case "session_update": { + const update = item.update; + switch (update.sessionUpdate) { + case "agent_message_chunk": + case "agent_thought_chunk": + return update.content.type === "text" + ? estimateTextHeight(update.content.text) + : 40; + case "tool_call": + return 40; + case "console": + return 40; + case "compact_boundary": + return 48; + case "status": + return 32; + case "error": + return 64; + case "task_notification": + return 56; + case "progress_group": + return ROW_PADDING + update.steps.length * 28; + // Rendered as null (folded into groups or invisible). + default: + return ROW_PADDING; + } + } + } +} + +/** + * Height guess for one thread row, fed to the virtualizer's estimateSize. + * A collapsed tool-call group is just its chip; an expanded one is the stack of + * its items. + */ +export function estimateThreadRow(row: ThreadRow): number { + if (row.kind === "item") return estimateConversationItem(row.item); + if (!row.expanded) return 44; + return ( + ROW_PADDING + row.items.reduce((h, i) => h + estimateConversationItem(i), 0) + ); +} From 80b441d83bc7f07b6ecf21df045d34c306cec23f Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Sat, 27 Jun 2026 17:38:42 +0300 Subject: [PATCH 2/5] fix(sessions): deepen overscan to kill scroll-up jank past heavy rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profiling the scroll-up showed the jank comes from tall, async-rendered rows (long markdown with code blocks, diffs): with overscan 6 each one was first measured and painted as it entered the viewport, so its true height replaced the estimate (≈300px of accumulated shift) and its paint landed on the visible frame (≈140ms hitches, ~25% dropped frames). Rendering 12 rows ahead moves that work off the visible critical path — measured shift drops to 0, worst frame to ~56ms, dropped frames to ~5%. Also size edit-diff rows by their changed lines (the unified diff collapses unchanged context, so the old whole-file guess over-estimated) and reserve a diff's measured height across remounts so it can't collapse to zero while its highlight worker re-initializes. Generated-By: PostHog Code Task-Id: f1ca2a14-42ac-49de-8ccd-b1f2de1e10e0 --- .../sessions/components/VirtualizedList.tsx | 32 +++++----- .../new-thread/estimateThreadRow.test.ts | 59 ++++++++++++++++++ .../new-thread/estimateThreadRow.ts | 61 +++++++++++++++++-- .../components/session-update/CodePreview.tsx | 26 +++++++- 4 files changed, 155 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/features/sessions/components/VirtualizedList.tsx b/packages/ui/src/features/sessions/components/VirtualizedList.tsx index f6ac04ca5..17cf749e1 100644 --- a/packages/ui/src/features/sessions/components/VirtualizedList.tsx +++ b/packages/ui/src/features/sessions/components/VirtualizedList.tsx @@ -18,12 +18,9 @@ interface VirtualizedListProps { renderItem: (item: T, index: number) => ReactNode; getItemKey?: (item: T, index: number) => string | number; /** - * Pre-measurement height guess per row. Rows are measured for real once they - * mount, but until then the virtualizer lays them out at this estimate. A - * thread that opens scrolled to the bottom never mounts the rows above, so - * the first scroll-up corrects every one of them from the estimate to its - * true height at once — the closer the estimate, the smaller each correction - * and the less the content shifts. Falls back to a flat constant when unset. + * Pre-measurement height guess per row, used until a row mounts and is + * measured. The closer the guess, the less unmeasured rows shift when first + * scrolled into view. Falls back to a flat constant when unset. */ estimateItemSize?: (item: T, index: number) => number; className?: string; @@ -48,7 +45,14 @@ export interface VirtualizedListHandle { const AT_BOTTOM_THRESHOLD = 50; const ESTIMATED_ROW_SIZE = 80; -const OVERSCAN = 6; +// Render this many rows beyond the viewport each way. Conversation rows are +// tall and expensive (markdown, code blocks, diffs) and render async, so a row +// first measured as it enters view both shifts the layout (its true height +// replaces the estimate) and stutters (its paint lands on the visible frame). +// A deep overscan moves that work ahead of the viewport: by the time a row is +// visible it is already measured and painted. Measured to erase the scroll-up +// layout shift; larger values add DOM cost without further smoothing. +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. const FAR_DRIFT_THRESHOLD = 400; @@ -127,15 +131,11 @@ function VirtualizedListInner( }, }); - // Keep already-visible content from jumping when a row ABOVE the viewport - // remeasures taller/shorter than its estimate. tanstack's default skips this - // compensation while scrolling backward (to avoid fighting momentum), but - // that's exactly when a first scroll-up corrects a run of never-measured rows - // — so the uncompensated growth shoves the viewport. Compensating - // unconditionally for above-viewport resizes holds the content steady. The - // bottom-follow path (anchorTo:"end") is handled before this predicate is - // consulted, so pinning to the end is unaffected. Not in 3.17's options type, - // so set on the instance directly. + // Compensate scrollTop for any above-viewport resize so visible content holds + // steady. tanstack's default skips this during backward scroll, which is + // exactly when a first scroll-up remeasures a run of never-measured rows and + // the growth shoves the viewport. The anchorTo:"end" bottom-follow runs before + // this predicate, so it stays unaffected. A runtime field, not an option. virtualizer.shouldAdjustScrollPositionOnItemSizeChange = ( item, _delta, diff --git a/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.test.ts b/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.test.ts index 24097625e..33158a60e 100644 --- a/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.test.ts +++ b/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.test.ts @@ -11,6 +11,24 @@ function itemRow(item: ConversationItem): ThreadRow { return { kind: "item", id: item.id, item }; } +function editToolCall(diff: { + path: string; + oldText?: string | null; + newText: string; +}): ConversationItem { + return { + type: "session_update", + id: "t", + update: { + sessionUpdate: "tool_call", + toolCallId: "t", + title: "Edit", + content: [{ type: "diff", ...diff }], + }, + turnContext: {} as never, + } as ConversationItem; +} + describe("estimateThreadRow", () => { it("estimates a collapsed tool group as just its chip", () => { const row: ThreadRow = { @@ -55,4 +73,45 @@ describe("estimateThreadRow", () => { ), ).toBeLessThan(80); }); + + it("estimates a new file by its full length, not a flat chip", () => { + const big = estimateThreadRow( + itemRow(editToolCall({ path: "src/a.ts", newText: "x\n".repeat(40) })), + ); + expect(big).toBeGreaterThan(300); + }); + + it("caps a huge diff at the CodePreview max height", () => { + const huge = estimateThreadRow( + itemRow(editToolCall({ path: "src/a.ts", newText: "x\n".repeat(5000) })), + ); + expect(huge).toBeLessThan(800); + }); + + it("sizes a small edit in a large file by its hunk, not the whole file", () => { + const file = Array(500).fill("line").join("\n"); + const edited = file.replace("line\nline\nline", "line\nCHANGED\nline"); + const smallEdit = estimateThreadRow( + itemRow( + editToolCall({ path: "src/a.ts", oldText: file, newText: edited }), + ), + ); + const fullFile = estimateThreadRow( + itemRow(editToolCall({ path: "src/a.ts", newText: file })), + ); + expect(smallEdit).toBeLessThan(400); + expect(smallEdit).toBeLessThan(fullFile); + }); + + it("estimates a plan-file edit as collapsed (header only)", () => { + const plan = estimateThreadRow( + itemRow( + editToolCall({ + path: "/home/user/.claude/plans/p.md", + newText: "x\n".repeat(200), + }), + ), + ); + expect(plan).toBeLessThan(80); + }); }); diff --git a/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.ts b/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.ts index d7ee226b5..91e2d630e 100644 --- a/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.ts +++ b/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.ts @@ -1,15 +1,22 @@ import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; import type { ThreadRow } from "@posthog/ui/features/sessions/components/new-thread/buildThreadGroups"; +import type { ToolCallContent } from "@posthog/ui/features/sessions/types"; -// Pre-measurement height guesses (px) for the conversation virtualizer. These -// only need to be in the right ballpark: the row is measured for real once it -// mounts, and the estimate's only job is to keep the first scroll-up from -// jumping as never-measured rows snap to their true height. Tuned against the -// 750px content column, so a wrapped line holds ~95 chars at ~22px tall. +// Ballpark row heights (px) for the virtualizer's estimateSize; a real measure +// replaces them on mount. Tuned to the 750px content column: ~95 chars per +// 22px line. const CHARS_PER_LINE = 95; const LINE_HEIGHT = 22; // itemClassName py-1.5 (6px top + bottom) wrapping every row. const ROW_PADDING = 12; +// An edit tool call (EditToolView) renders its diff expanded by default, so the +// body dominates the row. The unified diff collapses unchanged context, so its +// height tracks the changed lines plus a few context lines per hunk, not the +// whole file. CodePreview caps it at maxHeight 700px; a diff line is ~20px tall. +const TOOL_ROW_HEADER = 36; +const DIFF_LINE_HEIGHT = 20; +const DIFF_MAX_HEIGHT = 700; +const DIFF_CONTEXT_LINES = 8; function estimateTextHeight(text: string): number { let lines = 0; @@ -19,6 +26,48 @@ function estimateTextHeight(text: string): number { return ROW_PADDING + Math.max(1, lines) * LINE_HEIGHT; } +function countLines(text: string | null | undefined): number { + return text ? text.split("\n").length : 0; +} + +// Rough changed-line count by multiset difference — ignores position, but close +// enough to size a diff without running a real one. A new file (no oldText) is +// all additions and renders in full. +function changedLineCount( + oldText: string | null | undefined, + newText: string, +): number { + if (!oldText) return countLines(newText); + const freq = new Map(); + for (const line of oldText.split("\n")) { + freq.set(line, (freq.get(line) ?? 0) + 1); + } + let added = 0; + for (const line of newText.split("\n")) { + const seen = freq.get(line) ?? 0; + if (seen > 0) freq.set(line, seen - 1); + else added++; + } + let removed = 0; + for (const count of freq.values()) removed += count; + return added + removed; +} + +function estimateToolCall(content: ToolCallContent[] | undefined): number { + const diff = content?.find( + (c): c is Extract => c.type === "diff", + ); + if (!diff) return 40; + // Plan-file edits open collapsed (EditToolView defaultOpen={!isPlanFile}). + if ((diff.path ?? "").includes("claude/plans/")) { + return ROW_PADDING + TOOL_ROW_HEADER; + } + const lines = + changedLineCount(diff.oldText, diff.newText) + DIFF_CONTEXT_LINES; + const body = Math.min(lines * DIFF_LINE_HEIGHT, DIFF_MAX_HEIGHT); + return ROW_PADDING + TOOL_ROW_HEADER + body; +} + function estimateConversationItem(item: ConversationItem): number { switch (item.type) { case "user_message": { @@ -44,7 +93,7 @@ function estimateConversationItem(item: ConversationItem): number { ? estimateTextHeight(update.content.text) : 40; case "tool_call": - return 40; + return estimateToolCall(update.content); case "console": return 40; case "compact_boundary": diff --git a/packages/ui/src/features/sessions/components/session-update/CodePreview.tsx b/packages/ui/src/features/sessions/components/session-update/CodePreview.tsx index 3b1d29754..17ee0be0f 100644 --- a/packages/ui/src/features/sessions/components/session-update/CodePreview.tsx +++ b/packages/ui/src/features/sessions/components/session-update/CodePreview.tsx @@ -12,6 +12,13 @@ import { useCodePreviewExtensions, } from "./useCodePreviewExtensions"; +// Settled height (px) of each diff, keyed by its stable cacheKey. @pierre/diffs +// renders nothing until its highlight worker initializes, so a virtualized diff +// row collapses to ~0 on every remount and the list above it lurches. Reserving +// the last measured height as the container's min-height keeps the row stable +// across remounts. Lives at module scope so it survives unmount/remount. +const diffHeightCache = new Map(); + interface CodePreviewProps { content: string; filePath?: string; @@ -128,6 +135,19 @@ function DiffPreview({ const isDarkMode = useThemeStore((s) => s.isDarkMode); const fileName = filePath?.split("/").pop() ?? "file"; + const scrollRef = useRef(null); + const reservedHeight = cacheKey ? diffHeightCache.get(cacheKey) : undefined; + useEffect(() => { + const el = scrollRef.current; + if (!el || !cacheKey) return; + const ro = new ResizeObserver(() => { + const h = el.offsetHeight; + if (h > 0) diffHeightCache.set(cacheKey, h); + }); + ro.observe(el); + return () => ro.disconnect(); + }, [cacheKey]); + const oldFile = useMemo( () => ({ name: fileName, @@ -169,8 +189,12 @@ function DiffPreview({ )}
From e4448168da98aeff916fc49256044273c4abcf2a Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Sat, 27 Jun 2026 17:46:52 +0300 Subject: [PATCH 3/5] test(sessions): parameterize chip estimates; guard scroll-adjust assignment Address review: cover every chip-style row arm with it.each (exact px), and assign the scroll-compensation predicate once via a ref guard instead of on every render. Keep it in render, not a mount effect, so it precedes the virtualizer's first measurement pass. Generated-By: PostHog Code Task-Id: f1ca2a14-42ac-49de-8ccd-b1f2de1e10e0 --- .../sessions/components/VirtualizedList.tsx | 19 +++++++++--- .../new-thread/estimateThreadRow.test.ts | 31 ++++++++++++++----- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/features/sessions/components/VirtualizedList.tsx b/packages/ui/src/features/sessions/components/VirtualizedList.tsx index 17cf749e1..d6886512b 100644 --- a/packages/ui/src/features/sessions/components/VirtualizedList.tsx +++ b/packages/ui/src/features/sessions/components/VirtualizedList.tsx @@ -136,11 +136,20 @@ function VirtualizedListInner( // exactly when a first scroll-up remeasures a run of never-measured rows and // the growth shoves the viewport. The anchorTo:"end" bottom-follow runs before // this predicate, so it stays unaffected. A runtime field, not an option. - virtualizer.shouldAdjustScrollPositionOnItemSizeChange = ( - item, - _delta, - instance, - ) => item.start < (instance.scrollOffset ?? 0); + // + // Assigned once, synchronously in render, not in a mount effect: the + // virtualizer's first measurement pass runs during this render, so an effect + // would land too late and the default backward-scroll behavior would apply on + // that first measure. + const adjustAssignedRef = useRef(false); + if (!adjustAssignedRef.current) { + adjustAssignedRef.current = true; + virtualizer.shouldAdjustScrollPositionOnItemSizeChange = ( + item, + _delta, + instance, + ) => item.start < (instance.scrollOffset ?? 0); + } const settleAtEnd = useCallback(() => { if (settleRafRef.current !== null) { diff --git a/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.test.ts b/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.test.ts index 33158a60e..24198b72b 100644 --- a/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.test.ts +++ b/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.test.ts @@ -66,13 +66,30 @@ describe("estimateThreadRow", () => { expect(manyLines).toBeGreaterThan(oneLine); }); - it("keeps chip-style rows well under the old flat 80px guess", () => { - expect( - estimateThreadRow( - itemRow({ type: "git_action", id: "g", actionType: "push" }), - ), - ).toBeLessThan(80); - }); + it.each([ + ["git_action", { type: "git_action", id: "c", actionType: "push" }, 36], + [ + "skill_button_action", + { type: "skill_button_action", id: "c", buttonId: "add-analytics" }, + 44, + ], + [ + "git_action_result", + { type: "git_action_result", id: "c", actionType: "push", turnId: "t" }, + 64, + ], + ["turn_cancelled", { type: "turn_cancelled", id: "c" }, 40], + [ + "user_shell_execute", + { type: "user_shell_execute", id: "c", command: "ls", cwd: "/" }, + 64, + ], + ] as const satisfies readonly [string, ConversationItem, number][])( + "sizes the %s chip row at a fixed small height", + (_label, item, expected) => { + expect(estimateThreadRow(itemRow(item))).toBe(expected); + }, + ); it("estimates a new file by its full length, not a flat chip", () => { const big = estimateThreadRow( From 581a1cea64be3f934d27e34096a12fa15e62f43a Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Sat, 27 Jun 2026 17:52:36 +0300 Subject: [PATCH 4/5] docs(sessions): tighten scroll-jank fix comments Trim verbose comments to their load-bearing facts. Generated-By: PostHog Code Task-Id: f1ca2a14-42ac-49de-8ccd-b1f2de1e10e0 --- .../sessions/components/VirtualizedList.tsx | 30 +++++++------------ .../new-thread/estimateThreadRow.ts | 23 +++++--------- .../components/session-update/CodePreview.tsx | 8 ++--- 3 files changed, 21 insertions(+), 40 deletions(-) diff --git a/packages/ui/src/features/sessions/components/VirtualizedList.tsx b/packages/ui/src/features/sessions/components/VirtualizedList.tsx index d6886512b..8a7cff739 100644 --- a/packages/ui/src/features/sessions/components/VirtualizedList.tsx +++ b/packages/ui/src/features/sessions/components/VirtualizedList.tsx @@ -18,9 +18,8 @@ interface VirtualizedListProps { renderItem: (item: T, index: number) => ReactNode; getItemKey?: (item: T, index: number) => string | number; /** - * Pre-measurement height guess per row, used until a row mounts and is - * measured. The closer the guess, the less unmeasured rows shift when first - * scrolled into view. Falls back to a flat constant when unset. + * Pre-measurement height guess per row, until it mounts and is measured. Falls + * back to a flat constant when unset. */ estimateItemSize?: (item: T, index: number) => number; className?: string; @@ -45,13 +44,9 @@ export interface VirtualizedListHandle { const AT_BOTTOM_THRESHOLD = 50; const ESTIMATED_ROW_SIZE = 80; -// Render this many rows beyond the viewport each way. Conversation rows are -// tall and expensive (markdown, code blocks, diffs) and render async, so a row -// first measured as it enters view both shifts the layout (its true height -// replaces the estimate) and stutters (its paint lands on the visible frame). -// A deep overscan moves that work ahead of the viewport: by the time a row is -// visible it is already measured and painted. Measured to erase the scroll-up -// layout shift; larger values add DOM cost without further smoothing. +// 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. @@ -131,16 +126,11 @@ function VirtualizedListInner( }, }); - // Compensate scrollTop for any above-viewport resize so visible content holds - // steady. tanstack's default skips this during backward scroll, which is - // exactly when a first scroll-up remeasures a run of never-measured rows and - // the growth shoves the viewport. The anchorTo:"end" bottom-follow runs before - // this predicate, so it stays unaffected. A runtime field, not an option. - // - // Assigned once, synchronously in render, not in a mount effect: the - // virtualizer's first measurement pass runs during this render, so an effect - // would land too late and the default backward-scroll behavior would apply on - // that first measure. + // Hold visible content steady when an above-viewport row resizes — tanstack + // skips this during backward scroll, exactly when a first scroll-up remeasures a + // run of never-measured rows and the growth shoves the viewport. A runtime field, + // not an option; set once in render (not an effect) so it precedes the first + // measurement pass. const adjustAssignedRef = useRef(false); if (!adjustAssignedRef.current) { adjustAssignedRef.current = true; diff --git a/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.ts b/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.ts index 91e2d630e..9d839ed8f 100644 --- a/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.ts +++ b/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.ts @@ -2,17 +2,15 @@ import type { ConversationItem } from "@posthog/ui/features/sessions/components/ import type { ThreadRow } from "@posthog/ui/features/sessions/components/new-thread/buildThreadGroups"; import type { ToolCallContent } from "@posthog/ui/features/sessions/types"; -// Ballpark row heights (px) for the virtualizer's estimateSize; a real measure -// replaces them on mount. Tuned to the 750px content column: ~95 chars per -// 22px line. +// Ballpark row heights (px); a real measure replaces them on mount. Tuned to the +// 750px content column: ~95 chars per 22px line. const CHARS_PER_LINE = 95; const LINE_HEIGHT = 22; // itemClassName py-1.5 (6px top + bottom) wrapping every row. const ROW_PADDING = 12; -// An edit tool call (EditToolView) renders its diff expanded by default, so the -// body dominates the row. The unified diff collapses unchanged context, so its -// height tracks the changed lines plus a few context lines per hunk, not the -// whole file. CodePreview caps it at maxHeight 700px; a diff line is ~20px tall. +// Edit tool calls (EditToolView) render the diff expanded by default. The unified +// diff collapses unchanged context, so height tracks changed + a few context lines +// per hunk, not the whole file. Capped at 700px (CodePreview); ~20px per line. const TOOL_ROW_HEADER = 36; const DIFF_LINE_HEIGHT = 20; const DIFF_MAX_HEIGHT = 700; @@ -30,9 +28,8 @@ function countLines(text: string | null | undefined): number { return text ? text.split("\n").length : 0; } -// Rough changed-line count by multiset difference — ignores position, but close -// enough to size a diff without running a real one. A new file (no oldText) is -// all additions and renders in full. +// Rough changed-line count by multiset difference (ignores position) — enough to +// size a diff without running a real one. A new file (no oldText) renders in full. function changedLineCount( oldText: string | null | undefined, newText: string, @@ -114,11 +111,7 @@ function estimateConversationItem(item: ConversationItem): number { } } -/** - * Height guess for one thread row, fed to the virtualizer's estimateSize. - * A collapsed tool-call group is just its chip; an expanded one is the stack of - * its items. - */ +/** Height guess for one thread row, fed to the virtualizer's estimateSize. */ export function estimateThreadRow(row: ThreadRow): number { if (row.kind === "item") return estimateConversationItem(row.item); if (!row.expanded) return 44; diff --git a/packages/ui/src/features/sessions/components/session-update/CodePreview.tsx b/packages/ui/src/features/sessions/components/session-update/CodePreview.tsx index 17ee0be0f..6742879a3 100644 --- a/packages/ui/src/features/sessions/components/session-update/CodePreview.tsx +++ b/packages/ui/src/features/sessions/components/session-update/CodePreview.tsx @@ -12,11 +12,9 @@ import { useCodePreviewExtensions, } from "./useCodePreviewExtensions"; -// Settled height (px) of each diff, keyed by its stable cacheKey. @pierre/diffs -// renders nothing until its highlight worker initializes, so a virtualized diff -// row collapses to ~0 on every remount and the list above it lurches. Reserving -// the last measured height as the container's min-height keeps the row stable -// across remounts. Lives at module scope so it survives unmount/remount. +// Settled height (px) per diff, keyed by cacheKey. @pierre/diffs renders nothing +// until its highlight worker initializes, so a virtualized diff row collapses to ~0 +// on every remount. Module-scoped to survive unmount; reserved as min-height. const diffHeightCache = new Map(); interface CodePreviewProps { From c040879e5b4a2c0417dd2adb2dfa48d444bd1f64 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Sat, 27 Jun 2026 18:39:52 +0300 Subject: [PATCH 5/5] refactor(sessions): simplify scroll-up fix to the overscan bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profiling showed the deeper overscan (6→12) erases the scroll-up layout shift on its own — rows render and measure ahead of the viewport, so they enter view already sized and painted. The content-aware estimates, diff height-reservation, and scroll-compensation predicate were chasing the same jank before that landed and add nothing measurable on top, so drop them. Generated-By: PostHog Code Task-Id: f1ca2a14-42ac-49de-8ccd-b1f2de1e10e0 --- .../sessions/components/ConversationView.tsx | 2 - .../sessions/components/VirtualizedList.tsx | 28 +--- .../new-thread/estimateThreadRow.test.ts | 134 ------------------ .../new-thread/estimateThreadRow.ts | 121 ---------------- .../components/session-update/CodePreview.tsx | 24 +--- 5 files changed, 2 insertions(+), 307 deletions(-) delete mode 100644 packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.test.ts delete mode 100644 packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.ts diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index 85ce3c48b..d15543515 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -23,7 +23,6 @@ import type { ThreadRow, } from "@posthog/ui/features/sessions/components/new-thread/buildThreadGroups"; import type { CollapseMode } from "@posthog/ui/features/sessions/components/new-thread/conversationThreadConfig"; -import { estimateThreadRow } from "@posthog/ui/features/sessions/components/new-thread/estimateThreadRow"; import { createIncrementalThreadGrouper } from "@posthog/ui/features/sessions/components/new-thread/incrementalThreadGrouping"; import { ToolCallGroupChip } from "@posthog/ui/features/sessions/components/new-thread/ToolCallGroupChip"; import { SessionFooter } from "@posthog/ui/features/sessions/components/SessionFooter"; @@ -382,7 +381,6 @@ export function ConversationView({ ref={listRef} items={threadRows} getItemKey={getRowKey} - estimateItemSize={estimateThreadRow} renderItem={renderRow} onScrollStateChange={handleScrollStateChange} keepMounted={rowKeepMounted} diff --git a/packages/ui/src/features/sessions/components/VirtualizedList.tsx b/packages/ui/src/features/sessions/components/VirtualizedList.tsx index 8a7cff739..ef5ae0e17 100644 --- a/packages/ui/src/features/sessions/components/VirtualizedList.tsx +++ b/packages/ui/src/features/sessions/components/VirtualizedList.tsx @@ -17,11 +17,6 @@ interface VirtualizedListProps { items: T[]; renderItem: (item: T, index: number) => ReactNode; getItemKey?: (item: T, index: number) => string | number; - /** - * Pre-measurement height guess per row, until it mounts and is measured. Falls - * back to a flat constant when unset. - */ - estimateItemSize?: (item: T, index: number) => number; className?: string; itemClassName?: string; itemStyle?: CSSProperties; @@ -57,7 +52,6 @@ function VirtualizedListInner( items, renderItem, getItemKey, - estimateItemSize, className, itemClassName, itemStyle, @@ -109,12 +103,7 @@ function VirtualizedListInner( const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, - estimateSize: (index) => { - const item = items[index]; - return estimateItemSize && item !== undefined - ? estimateItemSize(item, index) - : ESTIMATED_ROW_SIZE; - }, + estimateSize: () => ESTIMATED_ROW_SIZE, overscan: OVERSCAN, anchorTo: "end", followOnAppend: true, @@ -126,21 +115,6 @@ function VirtualizedListInner( }, }); - // Hold visible content steady when an above-viewport row resizes — tanstack - // skips this during backward scroll, exactly when a first scroll-up remeasures a - // run of never-measured rows and the growth shoves the viewport. A runtime field, - // not an option; set once in render (not an effect) so it precedes the first - // measurement pass. - const adjustAssignedRef = useRef(false); - if (!adjustAssignedRef.current) { - adjustAssignedRef.current = true; - virtualizer.shouldAdjustScrollPositionOnItemSizeChange = ( - item, - _delta, - instance, - ) => item.start < (instance.scrollOffset ?? 0); - } - const settleAtEnd = useCallback(() => { if (settleRafRef.current !== null) { cancelAnimationFrame(settleRafRef.current); diff --git a/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.test.ts b/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.test.ts deleted file mode 100644 index 24198b72b..000000000 --- a/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; -import type { ThreadRow } from "@posthog/ui/features/sessions/components/new-thread/buildThreadGroups"; -import { estimateThreadRow } from "@posthog/ui/features/sessions/components/new-thread/estimateThreadRow"; -import { describe, expect, it } from "vitest"; - -function userMessage(content: string): ConversationItem { - return { type: "user_message", id: "u", content, timestamp: 0 }; -} - -function itemRow(item: ConversationItem): ThreadRow { - return { kind: "item", id: item.id, item }; -} - -function editToolCall(diff: { - path: string; - oldText?: string | null; - newText: string; -}): ConversationItem { - return { - type: "session_update", - id: "t", - update: { - sessionUpdate: "tool_call", - toolCallId: "t", - title: "Edit", - content: [{ type: "diff", ...diff }], - }, - turnContext: {} as never, - } as ConversationItem; -} - -describe("estimateThreadRow", () => { - it("estimates a collapsed tool group as just its chip", () => { - const row: ThreadRow = { - kind: "tool_group", - id: "g", - items: [userMessage("a".repeat(2000))], - summary: {} as never, - turnComplete: true, - expanded: false, - }; - expect(estimateThreadRow(row)).toBe(44); - }); - - it("estimates an expanded group as the stack of its items", () => { - const items = [userMessage("short"), userMessage("short")]; - const collapsed: ThreadRow = { - kind: "tool_group", - id: "g", - items, - summary: {} as never, - turnComplete: true, - expanded: false, - }; - const expanded: ThreadRow = { ...collapsed, expanded: true }; - expect(estimateThreadRow(expanded)).toBeGreaterThan( - estimateThreadRow(collapsed), - ); - }); - - it("scales a message estimate with its line count", () => { - const oneLine = estimateThreadRow(itemRow(userMessage("hi"))); - const manyLines = estimateThreadRow( - itemRow(userMessage(Array(10).fill("line").join("\n"))), - ); - expect(manyLines).toBeGreaterThan(oneLine); - }); - - it.each([ - ["git_action", { type: "git_action", id: "c", actionType: "push" }, 36], - [ - "skill_button_action", - { type: "skill_button_action", id: "c", buttonId: "add-analytics" }, - 44, - ], - [ - "git_action_result", - { type: "git_action_result", id: "c", actionType: "push", turnId: "t" }, - 64, - ], - ["turn_cancelled", { type: "turn_cancelled", id: "c" }, 40], - [ - "user_shell_execute", - { type: "user_shell_execute", id: "c", command: "ls", cwd: "/" }, - 64, - ], - ] as const satisfies readonly [string, ConversationItem, number][])( - "sizes the %s chip row at a fixed small height", - (_label, item, expected) => { - expect(estimateThreadRow(itemRow(item))).toBe(expected); - }, - ); - - it("estimates a new file by its full length, not a flat chip", () => { - const big = estimateThreadRow( - itemRow(editToolCall({ path: "src/a.ts", newText: "x\n".repeat(40) })), - ); - expect(big).toBeGreaterThan(300); - }); - - it("caps a huge diff at the CodePreview max height", () => { - const huge = estimateThreadRow( - itemRow(editToolCall({ path: "src/a.ts", newText: "x\n".repeat(5000) })), - ); - expect(huge).toBeLessThan(800); - }); - - it("sizes a small edit in a large file by its hunk, not the whole file", () => { - const file = Array(500).fill("line").join("\n"); - const edited = file.replace("line\nline\nline", "line\nCHANGED\nline"); - const smallEdit = estimateThreadRow( - itemRow( - editToolCall({ path: "src/a.ts", oldText: file, newText: edited }), - ), - ); - const fullFile = estimateThreadRow( - itemRow(editToolCall({ path: "src/a.ts", newText: file })), - ); - expect(smallEdit).toBeLessThan(400); - expect(smallEdit).toBeLessThan(fullFile); - }); - - it("estimates a plan-file edit as collapsed (header only)", () => { - const plan = estimateThreadRow( - itemRow( - editToolCall({ - path: "/home/user/.claude/plans/p.md", - newText: "x\n".repeat(200), - }), - ), - ); - expect(plan).toBeLessThan(80); - }); -}); diff --git a/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.ts b/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.ts deleted file mode 100644 index 9d839ed8f..000000000 --- a/packages/ui/src/features/sessions/components/new-thread/estimateThreadRow.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; -import type { ThreadRow } from "@posthog/ui/features/sessions/components/new-thread/buildThreadGroups"; -import type { ToolCallContent } from "@posthog/ui/features/sessions/types"; - -// Ballpark row heights (px); a real measure replaces them on mount. Tuned to the -// 750px content column: ~95 chars per 22px line. -const CHARS_PER_LINE = 95; -const LINE_HEIGHT = 22; -// itemClassName py-1.5 (6px top + bottom) wrapping every row. -const ROW_PADDING = 12; -// Edit tool calls (EditToolView) render the diff expanded by default. The unified -// diff collapses unchanged context, so height tracks changed + a few context lines -// per hunk, not the whole file. Capped at 700px (CodePreview); ~20px per line. -const TOOL_ROW_HEADER = 36; -const DIFF_LINE_HEIGHT = 20; -const DIFF_MAX_HEIGHT = 700; -const DIFF_CONTEXT_LINES = 8; - -function estimateTextHeight(text: string): number { - let lines = 0; - for (const segment of text.split("\n")) { - lines += Math.max(1, Math.ceil(segment.length / CHARS_PER_LINE)); - } - return ROW_PADDING + Math.max(1, lines) * LINE_HEIGHT; -} - -function countLines(text: string | null | undefined): number { - return text ? text.split("\n").length : 0; -} - -// Rough changed-line count by multiset difference (ignores position) — enough to -// size a diff without running a real one. A new file (no oldText) renders in full. -function changedLineCount( - oldText: string | null | undefined, - newText: string, -): number { - if (!oldText) return countLines(newText); - const freq = new Map(); - for (const line of oldText.split("\n")) { - freq.set(line, (freq.get(line) ?? 0) + 1); - } - let added = 0; - for (const line of newText.split("\n")) { - const seen = freq.get(line) ?? 0; - if (seen > 0) freq.set(line, seen - 1); - else added++; - } - let removed = 0; - for (const count of freq.values()) removed += count; - return added + removed; -} - -function estimateToolCall(content: ToolCallContent[] | undefined): number { - const diff = content?.find( - (c): c is Extract => c.type === "diff", - ); - if (!diff) return 40; - // Plan-file edits open collapsed (EditToolView defaultOpen={!isPlanFile}). - if ((diff.path ?? "").includes("claude/plans/")) { - return ROW_PADDING + TOOL_ROW_HEADER; - } - const lines = - changedLineCount(diff.oldText, diff.newText) + DIFF_CONTEXT_LINES; - const body = Math.min(lines * DIFF_LINE_HEIGHT, DIFF_MAX_HEIGHT); - return ROW_PADDING + TOOL_ROW_HEADER + body; -} - -function estimateConversationItem(item: ConversationItem): number { - switch (item.type) { - case "user_message": { - const attachments = item.attachments?.length ? 72 : 0; - return estimateTextHeight(item.content) + attachments + 16; - } - case "git_action": - return 36; - case "skill_button_action": - return 44; - case "git_action_result": - return 64; - case "turn_cancelled": - return 40; - case "user_shell_execute": - return 64; - case "session_update": { - const update = item.update; - switch (update.sessionUpdate) { - case "agent_message_chunk": - case "agent_thought_chunk": - return update.content.type === "text" - ? estimateTextHeight(update.content.text) - : 40; - case "tool_call": - return estimateToolCall(update.content); - case "console": - return 40; - case "compact_boundary": - return 48; - case "status": - return 32; - case "error": - return 64; - case "task_notification": - return 56; - case "progress_group": - return ROW_PADDING + update.steps.length * 28; - // Rendered as null (folded into groups or invisible). - default: - return ROW_PADDING; - } - } - } -} - -/** Height guess for one thread row, fed to the virtualizer's estimateSize. */ -export function estimateThreadRow(row: ThreadRow): number { - if (row.kind === "item") return estimateConversationItem(row.item); - if (!row.expanded) return 44; - return ( - ROW_PADDING + row.items.reduce((h, i) => h + estimateConversationItem(i), 0) - ); -} diff --git a/packages/ui/src/features/sessions/components/session-update/CodePreview.tsx b/packages/ui/src/features/sessions/components/session-update/CodePreview.tsx index 6742879a3..3b1d29754 100644 --- a/packages/ui/src/features/sessions/components/session-update/CodePreview.tsx +++ b/packages/ui/src/features/sessions/components/session-update/CodePreview.tsx @@ -12,11 +12,6 @@ import { useCodePreviewExtensions, } from "./useCodePreviewExtensions"; -// Settled height (px) per diff, keyed by cacheKey. @pierre/diffs renders nothing -// until its highlight worker initializes, so a virtualized diff row collapses to ~0 -// on every remount. Module-scoped to survive unmount; reserved as min-height. -const diffHeightCache = new Map(); - interface CodePreviewProps { content: string; filePath?: string; @@ -133,19 +128,6 @@ function DiffPreview({ const isDarkMode = useThemeStore((s) => s.isDarkMode); const fileName = filePath?.split("/").pop() ?? "file"; - const scrollRef = useRef(null); - const reservedHeight = cacheKey ? diffHeightCache.get(cacheKey) : undefined; - useEffect(() => { - const el = scrollRef.current; - if (!el || !cacheKey) return; - const ro = new ResizeObserver(() => { - const h = el.offsetHeight; - if (h > 0) diffHeightCache.set(cacheKey, h); - }); - ro.observe(el); - return () => ro.disconnect(); - }, [cacheKey]); - const oldFile = useMemo( () => ({ name: fileName, @@ -187,12 +169,8 @@ function DiffPreview({ )}