Skip to content

Extract thread grouping logic and add stable wrapper identity - #3937

Draft
adamleithp wants to merge 2 commits into
mainfrom
claude/new-ui-thread-scroll-lag-6033mk
Draft

Extract thread grouping logic and add stable wrapper identity#3937
adamleithp wants to merge 2 commits into
mainfrom
claude/new-ui-thread-scroll-lag-6033mk

Conversation

@adamleithp

Copy link
Copy Markdown
Contributor

Problem

The chat thread's tool-run and agent-turn grouping logic was embedded in ChatThread.tsx, making it hard to test and reuse. Additionally, during streaming, the grouping wrappers (ToolGroupItem, AgentTurn) were recreated on every chunk even when their member items hadn't changed, causing unnecessary re-renders of memoized rows and defeating the performance benefit of memoization.

Changes

Extract grouping logic to new module:

  • Created threadGrouping.ts with groupToolRuns(), groupIntoTurns(), and the new createStableTurnGrouper() function
  • Moved helper functions (isToolCallItem, isInvisibleItem, INVISIBLE_UPDATES) to the new module
  • Added comprehensive unit tests in threadGrouping.test.ts covering all grouping scenarios

Implement stable wrapper identity:

  • createStableTurnGrouper() preserves wrapper object identity across calls when member items are reference-equal
  • Reuses previous ToolGroupItem and AgentTurn wrappers if their member arrays haven't changed
  • Enables memoized rows to skip re-renders during streaming (only active-turn wrappers change per chunk)

Update ChatThread to use stable grouper:

  • Instantiate createStableTurnGrouper() once per mounted thread via useRef
  • Call turnGrouper.update(items) in the rows memo instead of groupIntoTurns(groupToolRuns(items))
  • Added effect to clear group overrides when collapse mode changes (matching legacy thread behavior)
  • Increased diff worker pool size from default 8 to 2 (conversation diffs don't need parallelism)

Update ToolGroup component:

  • Accept groupId prop (the run's first tool id) to key manual expand/collapse overrides
  • Implement collapse-mode logic: "all" keeps groups collapsed, "partial" streams live turn expanded then collapses on completion, "none" keeps everything expanded
  • Manual toggle overrides the mode until mode changes (thread wipes overrides then)
  • Use controlled open state instead of defaultOpen so groups actually collapse on turn completion

Update VirtualThreadScrollBody:

  • Add measureElementImmediately callback to resize rows synchronously on mount
  • Fixes paint-then-jump issue where rows scrolled into view would render at 80px estimate then resize

Update sessionViewStore:

  • Add useGroupOverride(id) hook for fine-grained subscription to a single group's override
  • Prevents re-rendering all mounted groups when any group's override changes

How did you test this?

  • Added 12 unit tests in threadGrouping.test.ts covering:
    • Tool run grouping with invisible items and visible breaks
    • Turn grouping with user-initiated boundaries
    • Stable wrapper identity preservation across streaming chunks
    • Wrapper rebuilding when members change or runs grow
  • Updated existing ToolGroup.test.tsx to pass new groupId prop
  • Verified existing tests pass with the refactored logic

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

https://claude.ai/code/session_01EAy2CqYiHQeDTgCEAvAZtG

…easure

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
@trunk-io

trunk-io Bot commented Jul 29, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

Both sides added an import at the same spot in ChatThread.tsx: keep the
branch's useSessionViewActions (collapse-mode overrides) alongside main's
useThreadScrollRequest (activity-pane scroll bridge, #3909).
@github-actions

Copy link
Copy Markdown

React Doctor found 1 issue in 1 file · 1 warning.

1 warning

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

Reviewed by React Doctor for commit a505535.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants