Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 8a5305f

Browse files
committed
fix(chat-thread): scroll lag — stable grouping, collapse mode, sync measure
The experimental ChatX thread lagged on scroll where the legacy ConversationView didn't. Three structural gaps, all app-side: - Grouping rebuilt every tool_group/agent_turn wrapper per streamed chunk, so the row memos missed and the whole transcript reconciled on every token. createStableTurnGrouper now reuses a wrapper whenever its member items are reference-equal (the conversation builder freezes completed turns and clones active-turn rows), confining identity churn to the live turn — the same bound the legacy thread gets from createIncrementalThreadGrouper. ThreadItemBody is memoized on item identity for the same reason. - ToolGroup ignored the conversationCollapseMode setting and used uncontrolled defaultOpen, so every group that streamed open stayed expanded (and mounted) for the life of the session. Open state is now controlled with legacy buildThreadGroups semantics — "all" collapses everything, "partial" collapses on turn completion, "none" never — with per-group manual overrides in sessionViewStore, wiped when the mode changes. A closed marker unmounts its body, keeping long transcripts' DOM (and the scroller engine's per-scroll child scans over it) bounded. - The windowed body measured rows via the virtualizer's async ResizeObserver path, painting one frame at the 80px estimate when scrolling through history; it now resizes synchronously on mount like the legacy VirtualizedList, and the diff worker pool is capped at 2 workers to match (the library default of 8 shiki isolates costs hundreds of MB). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAy2CqYiHQeDTgCEAvAZtG
1 parent 113eae9 commit 8a5305f

7 files changed

Lines changed: 460 additions & 120 deletions

File tree

packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx

Lines changed: 34 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,9 @@ import {
4949
import { MessageJumpPicker } from "@posthog/ui/features/sessions/components/chat-thread/MessageJumpPicker";
5050
import { MessageMinimap } from "@posthog/ui/features/sessions/components/chat-thread/MessageMinimap";
5151
import { ToolGroup } from "@posthog/ui/features/sessions/components/chat-thread/ToolGroup";
52+
import { createStableTurnGrouper } from "@posthog/ui/features/sessions/components/chat-thread/threadGrouping";
5253
import { THREAD_HOTKEY_OPTIONS } from "@posthog/ui/features/sessions/components/chat-thread/threadHotkeys";
5354
import {
54-
type AgentTurn,
5555
CHAT_THREAD_VIRTUALIZATION_THRESHOLD,
5656
completedTurnTimestamp,
5757
countFlatRows,
@@ -66,7 +66,6 @@ import { usePromptRecallSource } from "@posthog/ui/features/sessions/components/
6666
import { VirtualThreadScrollBody } from "@posthog/ui/features/sessions/components/chat-thread/VirtualThreadScrollBody";
6767
import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage";
6868
import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult";
69-
import { isUserInitiatedConversationItem } from "@posthog/ui/features/sessions/components/isUserInitiatedConversationItem";
7069
import { mergeConversationItems } from "@posthog/ui/features/sessions/components/mergeConversationItems";
7170
import { extractCanvasInstructions } from "@posthog/ui/features/sessions/components/session-update/canvasInstructions";
7271
import { extractChannelContext } from "@posthog/ui/features/sessions/components/session-update/channelContext";
@@ -90,6 +89,7 @@ import {
9089
useOptimisticItemsForTask,
9190
useSessionIsCloud,
9291
} from "@posthog/ui/features/sessions/sessionStore";
92+
import { useSessionViewActions } from "@posthog/ui/features/sessions/sessionViewStore";
9393
import type { UserMessageAttachment } from "@posthog/ui/features/sessions/userMessageTypes";
9494
import {
9595
SessionTaskIdProvider,
@@ -116,115 +116,6 @@ import {
116116
} from "react";
117117
import { useHotkeys } from "react-hotkeys-hook";
118118

119-
type SessionUpdateItem = Extract<ConversationItem, { type: "session_update" }>;
120-
121-
function isToolCallItem(item: ConversationItem): item is SessionUpdateItem {
122-
return (
123-
item.type === "session_update" && item.update.sessionUpdate === "tool_call"
124-
);
125-
}
126-
127-
/**
128-
* Session-updates that `SessionUpdateView` always renders as `null`. They produce no row, so they
129-
* must not break a contiguous tool run.
130-
*/
131-
const INVISIBLE_UPDATES = new Set([
132-
"user_message_chunk",
133-
"tool_call_update",
134-
"plan",
135-
"available_commands_update",
136-
"config_option_update",
137-
]);
138-
139-
/**
140-
* True when an item renders nothing, so it should be transparent to tool grouping. Besides the
141-
* always-null updates, this covers text chunks the stream emits with empty/whitespace or non-text
142-
* content (a stray empty `agent_message_chunk` between two tool calls is hidden via `empty:hidden`
143-
* but would otherwise split the run into two ungrouped markers).
144-
*/
145-
function isInvisibleItem(item: ConversationItem): boolean {
146-
if (item.type !== "session_update") return false;
147-
const update = item.update;
148-
if (INVISIBLE_UPDATES.has(update.sessionUpdate)) return true;
149-
if (
150-
update.sessionUpdate === "agent_message_chunk" ||
151-
update.sessionUpdate === "agent_thought_chunk"
152-
) {
153-
return update.content.type !== "text" || update.content.text.trim() === "";
154-
}
155-
return false;
156-
}
157-
158-
/**
159-
* Collapse each contiguous run of ≥2 tool-call updates into a single `ToolGroupItem`. A run is
160-
* broken by any *visible* non-tool item (prose, thought, status) so groups follow reading order;
161-
* invisible updates (see {@link INVISIBLE_UPDATES}) are transparent and don't split a run. A lone
162-
* tool call passes through untouched — it stays a single marker, matching the legacy thread.
163-
*/
164-
function groupToolRuns(items: ConversationItem[]): ThreadItem[] {
165-
const out: ThreadItem[] = [];
166-
// The buffer holds the active run: tool items plus any invisible items interleaved with them.
167-
let buffer: ConversationItem[] = [];
168-
let toolCount = 0;
169-
170-
const flush = () => {
171-
if (toolCount >= 2) {
172-
const tools = buffer.filter(isToolCallItem);
173-
out.push({ type: "tool_group", id: tools[0].id, tools });
174-
} else {
175-
out.push(...buffer);
176-
}
177-
buffer = [];
178-
toolCount = 0;
179-
};
180-
181-
for (const item of items) {
182-
if (isToolCallItem(item)) {
183-
buffer.push(item);
184-
toolCount++;
185-
} else if (isInvisibleItem(item)) {
186-
// Don't break the run; carry it along (it renders nothing wherever it lands).
187-
buffer.push(item);
188-
} else {
189-
flush();
190-
out.push(item);
191-
}
192-
}
193-
flush();
194-
return out;
195-
}
196-
197-
/**
198-
* Collapse each contiguous run of non-user rows into one {@link AgentTurn}, broken only by a
199-
* user-initiated row (which stays standalone so it remains the scroll anchor for the sticky header
200-
* and auto-follow). The turn block renders as a single muted card, tightening the spacing between
201-
* the agent's successive replies and tool calls.
202-
*/
203-
function groupIntoTurns(rows: ThreadItem[]): TurnRow[] {
204-
const out: TurnRow[] = [];
205-
let buffer: ThreadItem[] = [];
206-
const flush = () => {
207-
if (buffer.length > 0) {
208-
out.push({ type: "agent_turn", id: buffer[0].id, items: buffer });
209-
buffer = [];
210-
}
211-
};
212-
for (const row of rows) {
213-
// git_action and skill_button_action stand in for the user's message when the prompt was a
214-
// git operation or a skill button click (see handlePromptRequest) — they open a turn just
215-
// like a user message, so they break the agent card too rather than render inside it as if
216-
// they were agent output. Same boundary set as the legacy view's buildThreadGroups.
217-
if (isUserInitiatedConversationItem(row)) {
218-
flush();
219-
out.push(row);
220-
} else {
221-
buffer.push(row);
222-
}
223-
}
224-
flush();
225-
return out;
226-
}
227-
228119
function formatTimestamp(ts: number): string {
229120
return new Date(ts).toLocaleString([], {
230121
month: "short",
@@ -511,8 +402,11 @@ const AgentProse = memo(function AgentProse({
511402

512403
/** Renders a single thread item's body (no scroller wrapper), reused for standalone rows and for
513404
* each item inside an agent-turn card. `isTrailing` marks the turn's last item — a trailing tool
514-
* group of a streaming turn may still grow, so its label stays "Using …" between tool calls. */
515-
function ThreadItemBody({
405+
* group of a streaming turn may still grow, so its label stays "Using …" between tool calls.
406+
*
407+
* Memoized on item identity: the conversation builder freezes completed turns' items and clones
408+
* active-turn items per chunk, so identity equality is exactly content equality. */
409+
const ThreadItemBody = memo(function ThreadItemBody({
516410
item,
517411
renderItem,
518412
isTrailing = false,
@@ -529,6 +423,7 @@ function ThreadItemBody({
529423
!!context && !context.turnComplete && !context.turnCancelled;
530424
return (
531425
<ToolGroup
426+
groupId={item.id}
532427
tools={item.tools}
533428
mayStillGrow={isTrailing && turnStreaming}
534429
/>
@@ -545,13 +440,13 @@ function ThreadItemBody({
545440
);
546441
}
547442
return <>{renderItem(item)}</>;
548-
}
443+
});
549444

550445
/**
551446
* One transcript row. Memoized and scroll-state-free, so rows never re-render while scrolling — the
552447
* non-virtualized thread stays cheap. The pinned header is the separate overlay, not the rows.
553448
*
554-
* An {@link AgentTurn} renders as a single muted card wrapping its items with tight spacing; a user
449+
* An `AgentTurn` renders as a single muted card wrapping its items with tight spacing; a user
555450
* message stays a standalone anchored row.
556451
*/
557452
const ThreadRow = memo(function ThreadRow({
@@ -1018,6 +913,10 @@ function ChatThreadRenderer({
1018913
() => ({
1019914
workerFactory: () => diffWorkerFactory(),
1020915
totalASTLRUCacheSize: 200,
916+
// Each pooled highlighter worker is a full V8 isolate with shiki
917+
// grammars loaded (~40MB RSS); the library default of 8 costs hundreds
918+
// of MB for parallelism conversation diffs don't need.
919+
poolSize: 2,
1021920
}),
1022921
[diffWorkerFactory],
1023922
);
@@ -1031,11 +930,29 @@ function ChatThreadRenderer({
1031930
[conversationItems, optimisticItems, isCloud],
1032931
);
1033932

933+
// Identity-preserving grouping: per streamed chunk, only the active turn's
934+
// wrappers change, so the memoized rows skip everything else (see
935+
// createStableTurnGrouper). One grouper per mounted thread — `key={taskId}`
936+
// above remounts (and so resets) it on task switch.
937+
const turnGrouperRef = useRef<ReturnType<
938+
typeof createStableTurnGrouper
939+
> | null>(null);
940+
turnGrouperRef.current ??= createStableTurnGrouper();
941+
const turnGrouper = turnGrouperRef.current;
1034942
const rows = useMemo<TurnRow[]>(
1035-
() => groupIntoTurns(groupToolRuns(items)),
1036-
[items],
943+
() => turnGrouper.update(items),
944+
[items, turnGrouper],
1037945
);
1038946

947+
// Changing the global collapse mode wipes ephemeral per-chip overrides, so
948+
// every group snaps to the new mode's base state (same as the legacy view).
949+
const sessionViewActions = useSessionViewActions();
950+
const collapseMode = useSettingsStore((s) => s.conversationCollapseMode);
951+
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally keyed on collapseMode only
952+
useEffect(() => {
953+
sessionViewActions.clearGroupOverrides();
954+
}, [collapseMode]);
955+
1039956
// Virtualization ratchet: past the threshold the thread switches to the windowed body and
1040957
// stays there for the life of this mount (see CHAT_THREAD_VIRTUALIZATION_THRESHOLD). Long
1041958
// sessions start virtualized from the first render; a live session flips once mid-stream,

packages/ui/src/features/sessions/components/chat-thread/ToolGroup.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ describe("ToolGroup", () => {
4040
<ServiceProvider container={new Container()}>
4141
<Theme>
4242
<ToolGroup
43+
groupId="spawn-1"
4344
tools={[subagentItem("spawn-1"), subagentItem("spawn-2")]}
4445
/>
4546
</Theme>
@@ -54,6 +55,7 @@ describe("ToolGroup", () => {
5455
<ServiceProvider container={new Container()}>
5556
<Theme>
5657
<ToolGroup
58+
groupId="spawn-1"
5759
tools={[
5860
subagentItem("spawn-1", {
5961
status: "in_progress",

packages/ui/src/features/sessions/components/chat-thread/ToolGroup.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import {
77
Spinner,
88
} from "@posthog/quill";
99
import { readAgentToolName } from "@posthog/shared";
10+
import {
11+
useGroupOverride,
12+
useSessionViewActions,
13+
} from "@posthog/ui/features/sessions/sessionViewStore";
1014
import type { ToolCall } from "@posthog/ui/features/sessions/types";
15+
import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
1116
import { memo } from "react";
1217
import type { ConversationItem } from "../buildConversationItems";
1318
import { grouping } from "../new-thread/conversationThreadConfig";
@@ -81,12 +86,22 @@ export function isToolActive(item: ToolGroupItem["tools"][number]): boolean {
8186
* collapsible body holds each tool's own marker via `SessionUpdateView` (which dispatches through
8287
* `ToolCallBlock` → `ToolRow` → `ChatMarker`).
8388
*
84-
* Expanded by default while the turn is still running (live visibility), collapsed once complete.
89+
* Open state follows the global collapse mode (same semantics as the legacy thread's
90+
* `buildThreadGroups`): "all" keeps every group collapsed, "partial" streams the live turn
91+
* expanded and collapses it when the turn settles, "none" keeps everything expanded. A manual
92+
* toggle overrides the mode for this group until the mode changes (the thread wipes overrides
93+
* then). Controlled — not `defaultOpen` — so a group that streamed open actually collapses on
94+
* completion instead of staying mounted for the session's life; a closed marker unmounts its
95+
* body, which is what keeps a long transcript's DOM (and the scroller engine's per-scroll scans
96+
* over it) bounded.
8597
*/
8698
export const ToolGroup = memo(function ToolGroup({
99+
groupId,
87100
tools,
88101
mayStillGrow = false,
89102
}: {
103+
/** Stable row id (the run's first tool), keying this group's manual expand/collapse override. */
104+
groupId: string;
90105
tools: ToolGroupItem["tools"];
91106
/**
92107
* True when this run is the turn's trailing content and the turn is still
@@ -98,8 +113,17 @@ export const ToolGroup = memo(function ToolGroup({
98113
mayStillGrow?: boolean;
99114
}) {
100115
const turnComplete = tools[0]?.turnContext.turnComplete ?? false;
116+
const turnCancelled = tools[0]?.turnContext.turnCancelled ?? false;
101117
const isActive = tools.some(isToolActive) || mayStillGrow;
102118

119+
const collapseMode = useSettingsStore((s) => s.conversationCollapseMode);
120+
const override = useGroupOverride(groupId);
121+
const { setGroupOverride } = useSessionViewActions();
122+
const settled = turnComplete || turnCancelled;
123+
const baseCollapse =
124+
collapseMode === "all" || (collapseMode === "partial" && settled);
125+
const open = override ?? !baseCollapse;
126+
103127
// Uniform when every tool in the run shares the same name/kind — then we can name it.
104128
const keys = tools.map(toolKey);
105129
const uniform = keys.every((k) => k === keys[0]);
@@ -114,7 +138,8 @@ export const ToolGroup = memo(function ToolGroup({
114138

115139
return (
116140
<ChatMarker
117-
defaultOpen={!turnComplete}
141+
open={open}
142+
onOpenChange={(next) => setGroupOverride(groupId, next)}
118143
body={tools.map((item) => (
119144
<SessionUpdateView
120145
key={item.id}

packages/ui/src/features/sessions/components/chat-thread/VirtualThreadScrollBody.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,20 @@ export function VirtualThreadScrollBody({
327327
viewportRef,
328328
);
329329

330+
// Resize synchronously on mount: the virtualizer's own measureElement path is
331+
// ResizeObserver-fed and lands a frame late, so a row scrolled into view
332+
// would paint once at the 80px estimate and then jump. Same recipe as the
333+
// legacy VirtualizedList.
334+
const measureElementImmediately = useCallback(
335+
(node: HTMLDivElement | null) => {
336+
virtualizer.measureElement(node);
337+
if (!node) return;
338+
const index = Number(node.dataset.index);
339+
virtualizer.resizeItem(index, node.offsetHeight);
340+
},
341+
[virtualizer],
342+
);
343+
330344
const userRows = useMemo(() => {
331345
const result: UserRow[] = [];
332346
flatRows.forEach((row, index) => {
@@ -439,7 +453,7 @@ export function VirtualThreadScrollBody({
439453
return (
440454
<div
441455
key={virtualItem.key}
442-
ref={virtualizer.measureElement}
456+
ref={measureElementImmediately}
443457
data-index={virtualItem.index}
444458
className="absolute top-0 left-0 w-full"
445459
style={{ transform: `translateY(${virtualItem.start}px)` }}

0 commit comments

Comments
 (0)