From eeecf77b462c876aa23deaaccd3575b01c8480a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 23:29:49 +0000 Subject: [PATCH 1/6] feat(chat-thread): virtualize the experimental thread past a row threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users report the app locking up in long threads with the experimental (ChatX) thread UI: unlike the legacy ConversationView (always-on VirtualizedList), it mounts every row and re-reconciles the whole transcript per streamed chunk, with content-visibility only bounding paint — not React or DOM cost. Following the upstream MessageScroller guidance (virtualization lives outside the primitive; add a virtualizer when the transcript needs it), the thread now ratchets once past 150 flat rows into a windowed body: the quill viewport stays the scroll element and @tanstack/react-virtual owns the rows inside ChatMessageScrollerContent. Below the threshold nothing changes, keeping the engine's anchor-scroll UX and native find/selection for typical sessions. - Agent turns flatten to one row per item so a single giant turn (autonomous sessions) can't defeat windowing; turn grouping survives via per-row flags, and the completed-turn timestamp rides the turn's last row. - Follow-bottom uses the proven VirtualizedList recipe (anchorTo end, followOnAppend, footer height as paddingEnd, totalSize re-pin, settle-at-end loop) plus the submit-from-anywhere recapture the non-virtualized ThreadAutoFollow provides. - Sticky header, jump picker, keyboard nav, and the scrollbar rail get windowed implementations (virtualizer measurements + scrollToIndex); the rail already interpolates unmounted rows. - Crossing the threshold mid-session resumes from the scroll state the non-virtualized body records, so the flip doesn't yank the viewport. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PZje6JV5QZEqmhFKrhLDZ7 --- .../components/chat-thread/ChatThread.tsx | 782 ++++++++++++++++-- .../chat-thread/threadVirtualization.test.ts | 188 +++++ .../chat-thread/threadVirtualization.ts | 139 ++++ 3 files changed, 1023 insertions(+), 86 deletions(-) create mode 100644 packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.test.ts create mode 100644 packages/ui/src/features/sessions/components/chat-thread/threadVirtualization.ts diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 1365e714ff..b3f52ae3f8 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -48,11 +48,21 @@ import { type PromptRecallHandler, } from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; import { MessageJumpPicker } from "@posthog/ui/features/sessions/components/chat-thread/MessageJumpPicker"; -import { - ToolGroup, - type ToolGroupItem, -} from "@posthog/ui/features/sessions/components/chat-thread/ToolGroup"; +import { ToolGroup } from "@posthog/ui/features/sessions/components/chat-thread/ToolGroup"; import { THREAD_HOTKEY_OPTIONS } from "@posthog/ui/features/sessions/components/chat-thread/threadHotkeys"; +import { + type AgentTurn, + CHAT_THREAD_VIRTUALIZATION_THRESHOLD, + completedTurnTimestamp, + computeStickyAnchor, + countFlatRows, + type FlatThreadRow, + flattenTurnRows, + type StickyAnchorEntry, + type StickyAnchorState, + type ThreadItem, + type TurnRow, +} from "@posthog/ui/features/sessions/components/chat-thread/threadVirtualization"; 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"; @@ -91,9 +101,11 @@ import { type DiffWorkerFactory, } from "@posthog/ui/shell/diffWorkerHost"; import { IconButton, Tooltip } from "@radix-ui/themes"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { memo, + type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, useCallback, @@ -105,19 +117,13 @@ import { } from "react"; import { useHotkeys } from "react-hotkeys-hook"; -/** A row is either a parsed conversation item or a synthesized group of tool calls. */ -type ThreadItem = ConversationItem | ToolGroupItem; +type SessionUpdateItem = Extract; /** - * A contiguous run of non-user rows (assistant prose, tools, git actions, ...) shown as one - * `bg-muted/30` block with tight internal spacing. Broken only by a user message. + * How far below the viewport top a user message may sit while still counting as the current + * anchor — shared by the engine (`scrollPreviousItemPeek`) and the virtualized sticky header. */ -type AgentTurn = { type: "agent_turn"; id: string; items: ThreadItem[] }; - -/** Top-level row: a standalone user message, or a grouped agent turn. */ -type TurnRow = ThreadItem | AgentTurn; - -type SessionUpdateItem = Extract; +const SCROLL_PREVIOUS_ITEM_PEEK = 64; function isToolCallItem(item: ConversationItem): item is SessionUpdateItem { return ( @@ -485,7 +491,6 @@ function MessageCopyButton({ function StickyHeaderOverlay({ items }: { items: ConversationItem[] }) { const { currentAnchorId } = useChatMessageScrollerVisibility(); const { scrollToMessage } = useChatMessageScroller(); - const shouldReduceMotion = useReducedMotion(); const [dismissedId, setDismissedId] = useState(null); const [offscreen, setOffscreen] = useState(false); // Anchor element used only to locate the enclosing scroller/viewport in the DOM. @@ -546,46 +551,100 @@ function StickyHeaderOverlay({ items }: { items: ConversationItem[] }) {