@@ -49,9 +49,9 @@ import {
4949import { MessageJumpPicker } from "@posthog/ui/features/sessions/components/chat-thread/MessageJumpPicker" ;
5050import { MessageMinimap } from "@posthog/ui/features/sessions/components/chat-thread/MessageMinimap" ;
5151import { ToolGroup } from "@posthog/ui/features/sessions/components/chat-thread/ToolGroup" ;
52+ import { createStableTurnGrouper } from "@posthog/ui/features/sessions/components/chat-thread/threadGrouping" ;
5253import { THREAD_HOTKEY_OPTIONS } from "@posthog/ui/features/sessions/components/chat-thread/threadHotkeys" ;
5354import {
54- type AgentTurn ,
5555 CHAT_THREAD_VIRTUALIZATION_THRESHOLD ,
5656 completedTurnTimestamp ,
5757 countFlatRows ,
@@ -66,7 +66,6 @@ import { usePromptRecallSource } from "@posthog/ui/features/sessions/components/
6666import { VirtualThreadScrollBody } from "@posthog/ui/features/sessions/components/chat-thread/VirtualThreadScrollBody" ;
6767import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage" ;
6868import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult" ;
69- import { isUserInitiatedConversationItem } from "@posthog/ui/features/sessions/components/isUserInitiatedConversationItem" ;
7069import { mergeConversationItems } from "@posthog/ui/features/sessions/components/mergeConversationItems" ;
7170import { extractCanvasInstructions } from "@posthog/ui/features/sessions/components/session-update/canvasInstructions" ;
7271import { 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" ;
9393import type { UserMessageAttachment } from "@posthog/ui/features/sessions/userMessageTypes" ;
9494import {
9595 SessionTaskIdProvider ,
@@ -116,115 +116,6 @@ import {
116116} from "react" ;
117117import { 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-
228119function 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 */
557452const 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,
0 commit comments