perf(web): streamed reply text no longer re-renders ChatView#23117

merged3
main
perf/split4-projection-selectors
opened2d agobyJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 2d ago

ChatView subscribed to the active thread's live projection. Every streamed assistant or reasoning delta replaced a turn item, so ChatView re-rendered once per delta, and its compiled output rebuilt the uncached region inside ThreadFindCanvas. A long reply re-rendered ChatView about once per delta.

ChatView now reads a quiet view of the thread, and the components that show the streamed text read the live one:

  • client-runtime gains quietThreadAtom and quietVisibleTurnItemsAtom, next to the existing detail atoms in threadDetail.ts. They keep the previous value while the only change is text streamed into existing assistant or reasoning items. Any other change passes through: a new item, a status or streaming change, runs, requests, sessions, plans. Mobile can use the same atoms.
  • The stale fields are stripped, not just ignored. quietThreadAtom returns a ThreadStructure (in models.ts): the projection without updatedAt, with assistant and reasoning items lacking text and updatedAt. A reader of the quiet thread can't pick up a stale value, because the type and the runtime object no longer have it. The structure is built only when sameThreadStructure sees a change. Items are stripped once each (WeakMap), so between changes the atom returns the same object and allocates nothing per delta. The comparison overlays the stripped form of the previous item on the next one, so the strip function and the ignored fields can't drift apart.
  • The helpers ChatView hands the thread to now take the narrow type: thread execution and runtime, workflows, handoff, pending requests, plans, context window, worktree setup, rewind, timeline follow, queued-run edit, attachment preview handoff. Two needed a field the structure drops:
    • deriveThreadRuntime copied projection.updatedAt into runtime.updatedAt. ChatView now uses deriveThreadStructureRuntime, which has no updatedAt. The shell-less fallback (presentThreadShellFromProjection) reads the live updatedAt through useThreadUpdatedAt, subscribed only while there is no shell.
    • A plan with no plan item fell back to projection.updatedAt for its time. It now falls back to its run's timestamps.
  • ChatView's own timeline entries (anchoring, rewind, prompt history, the draft hero) leave out assistant and reasoning rows, since their text is gone. They only use user messages and work rows. A reasoning row still releases the new-turn anchor, and a timeline with only streamed rows still counts as non-empty.
  • ChatView reads the thread, its visible turn items and the workflow parent through those atoms. Every value it derives from the projection depends on the thread's structure, not on assistant or reasoning text. That covers runs, pending requests, plans, worktree setup, prompt history (user messages only) and the context window (compaction items and provider usage).
  • ThreadTimeline (new, 65 lines) wraps MessagesTimeline. It reads the live visible turn items, derives the rows it renders, and remembers the ready timeline that a thread switch paints. That layout effect moved here from ChatView, so the held paint has the current text. ChatView still derives rows from the quiet items for its own uses: anchoring, prompt history, rewind and the draft hero.
  • useThreadFind reads the live projection itself instead of taking it as a prop, so open find results still refresh while a reply streams. Only the find provider re-renders per delta, and its children are unchanged elements.

Renders, median of 3 (rc-init.js), on the current head. Before (#23117's base, 3b95e7831b) and after ran back to back on the same seeded thread with Codex:

Interactionbefore: totalafter: totalbefore: ChatViewafter: ChatViewbefore: MessagesTimelineafter: MessagesTimeline
Streamed reply, 15 short lines2,5202,39022182222
Streamed reply, 40 lines3,4933,20432183231
Thread switch and back2,5502,39413101310
Type 20 chars1661671111
Right panel open and close4804844444
Raw runs (total / ChatView / MessagesTimeline)
Interactionbeforeafter
Streamed reply, 15 short lines2,564/22/22, 2,355/20/20, 2,520/23/232,678/21/25, 2,390/17/22, 2,293/18/21
Streamed reply, 40 lines4,214/40/40, 3,406/32/32, 3,493/32/323,530/20/34, 3,204/18/31, 3,170/17/30
Thread switch and back3,303/19/19, 2,550/13/13, 2,206/11/113,274/19/19, 2,394/10/10, 2,132/10/10
Type 20 chars166/1/1 ×3167/1/1 ×3
Right panel open and close491/4/4, 480/4/4, 480/4/4495/4/4, 484/4/4, 484/4/4

ChatView renders per reply no longer grow with the reply's length: 18 for both the short and the long reply, against 22 and 32 before. The remaining renders come from run, session, provider-turn and shell changes. ChatView reads each of those (stop button, working state, runtime, token usage). Totals fall by 5–8%. Markdown rows inside the timeline make up most of the rest, and they render once per delta either way. The first thread switch in each set includes a cold history load, which is why it runs high on both sides. A skipped ChatView render costs more than its count suggests: in an earlier profile it averaged about 14 ms of self time.

Filebeforeafter
ChatView.tsx3,8963,908
chat/ThreadTimeline.tsx65

Tested:

  • threadDetail.test.ts (new) covers what the quiet atom holds through and what it passes on, that the atom keeps its identity through text and updatedAt changes, and that stripped fields are absent at runtime. threads-atoms.test.ts, threadRequests.test.ts, threadExecution.test.ts, session-logic.test.ts, ChatView.logic.test.ts and contextWindow.test.ts pass.
  • Typecheck of client-runtime, shared, apps/web and apps/mobile. Lint and format on the touched files. React Compiler: 0 bailouts on ChatView, ThreadTimeline, useThreadFind and entities.
  • Hands-on in a dev build against an isolated database and a throwaway repo, all with Codex, re-run on the #23124 head (9cdd2360b2):
    • Ctrl+F open during a stream, where the match count kept growing (509 → 515 → 518);
    • Stop mid-stream, which showed "Run interrupted";
    • switching threads mid-stream and back, where the reply kept streaming and finished;
    • sending from a draft thread, which promoted to the server thread and streamed;
    • a supervised command approval, approved, and the file was written;
    • a plan-mode turn, which showed "Plan ready" and Implement;
    • a turn at High effort, where "Thinking" and reasoning summary rows stream before the reply. The timeline stayed pinned to the end through the reasoning rows and the reply, the same as on the base in the same sequence.
  • Not reproduced: subagent, goal and background-work banners. ChatView still reads their inputs (shell, runs, turn items) through the quiet atom, which passes every change to them.

🤖 Generated with Claude Code

Checks

All checks passed
Synced from GitHub

Approvability

Verdict: Needs Human Approval

Macroscope's review found this PR requires human approval — This PR substantially changes production thread-state subscriptions and timeline rendering by introducing a quiet/live projection split and a new timeline component. The change spans the central ChatView and shared client-runtime abstractions, so its broader runtime effects merit human review.

You can add or adjust custom eligibility rules. Learn more.

Synced from GitHub

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire5.0 KiB5.0 KiB0 B (0.0%)6.8 KiB✅
CodexThread snapshot wire3.8 KiB3.8 KiB0 B (0.0%)4.9 KiB✅
CodexLive turn WebSocket wire1.2 KiB1.2 KiB0 B (0.0%)2.0 KiB✅
CodexLive turn WebSocket decoded20.9 KiB20.9 KiB0 B (0.0%)29.3 KiB✅
CodexLive turn messages220 (0.0%)8✅
ClaudeTotal thread wire5.0 KiB5.0 KiB+24 B (+0.5%)6.8 KiB✅
ClaudeThread snapshot wire3.8 KiB3.8 KiB0 B (0.0%)4.9 KiB✅
ClaudeLive turn WebSocket wire1.2 KiB1.2 KiB+24 B (+2.0%)2.0 KiB✅
ClaudeLive turn WebSocket decoded21.2 KiB21.2 KiB+41 B (+0.2%)29.3 KiB✅
ClaudeLive turn messages12+1 (+100.0%)8✅

Baseline: 3b95e78 · PR result: 0c8b651 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 108.5 KiB
  • Claude decoded thread snapshot: 108.8 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

Synced from GitHub

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Path: .coderabbit.config.ts
  • Review profile: CHILL
  • Plan: Team
  • Run ID: 0d11c0aa-8c68-41b8-991a-fb4bed45304b
📥 Commits

Reviewing files that changed from the base of the PR and between c22776b1927faefd332ed4a6b7c45912229815b3 and 0c8b6515df8d597e318ee8f4f3d30da07d84eea7.

📒 Files selected for processing: 21
  • apps/web/src/components/ChatView.logic.ts
  • apps/web/src/components/ChatView.tsx
  • apps/web/src/components/chat/useAttachmentPreviewHandoff.ts
  • apps/web/src/components/chat/useComposerSelection.ts
  • apps/web/src/components/chat/usePendingThreadRequests.ts
  • apps/web/src/components/chat/useQueuedRunEdit.ts
  • apps/web/src/components/chat/useTimelineFollow.ts
  • apps/web/src/components/chat/useTimelineRewind.ts
  • apps/web/src/components/chat/useWorktreeSetup.ts
  • apps/web/src/lib/contextWindow.ts
  • apps/web/src/session-logic.ts
  • apps/web/src/state/entities.ts
  • packages/client-runtime/src/handoff.ts
  • packages/client-runtime/src/state/models.ts
  • packages/client-runtime/src/state/threadDetail.test.ts
  • packages/client-runtime/src/state/threadDetail.ts
  • packages/client-runtime/src/state/threadExecution.ts
  • packages/client-runtime/src/state/threadRequests.ts
  • packages/client-runtime/src/state/threadWorkflows.ts
  • packages/client-runtime/src/state/turnItemPresentation.ts
  • packages/shared/src/orchestrationV2ThreadError.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.

📝 Walkthrough

The changes add quiet thread structures and selectors, update runtime and other consumers to use structure-based data, and move timeline entry selection and persistence into ThreadTimeline. ChatView filters incomplete rows and accounts for streamed rows in timeline loading decisions.

Changes: Thread Rendering

Layer / File(s)Summary
Quiet thread structures
packages/client-runtime/src/state/models.ts, packages/client-runtime/src/state/threadDetail.ts, packages/client-runtime/src/state/threadDetail.test.ts, apps/web/src/state/entities.ts, apps/web/src/components/chat/useAttachmentPreviewHandoff.ts
Thread structure types omit streamed text and timestamps. Quiet atoms retain prior structures when only those fields change. Selector hooks expose quiet projections, update timestamps, and visible turn items. Tests cover structure comparison and quiet atom behavior.
Structure-based derivation and consumers
packages/client-runtime/src/state/threadExecution.ts, packages/client-runtime/src/state/threadRequests.ts, packages/client-runtime/src/state/threadWorkflows.ts, packages/client-runtime/src/state/turnItemPresentation.ts, packages/client-runtime/src/handoff.ts, packages/shared/src/orchestrationV2ThreadError.ts, apps/web/src/components/ChatView.logic.ts, apps/web/src/components/chat/*, apps/web/src/lib/contextWindow.ts, apps/web/src/session-logic.ts, apps/web/src/components/ChatView.tsx
Runtime, request, workflow, handoff, and presentation inputs now use thread structure types. Plan timestamps use matching plan-item timestamps when available, with run and thread timestamps as fallbacks. Timeline follow releases its anchor under the described reasoning and live-follow conditions.
Timeline selection and ChatView integration
apps/web/src/components/chat/ThreadTimeline.tsx, apps/web/src/components/ChatView.tsx, apps/web/src/components/chat/useThreadFind.ts
ThreadTimeline selects draft or server entries, resolves remembered timelines during thread switches, remembers eligible ready timelines, and renders through MessagesTimeline. ChatView builds server entries from complete rows and includes streamed rows in its loading-state decision. useThreadFind obtains content through useThreadProjection.

Priority: ⬇️ Low

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Refactor

Possibly related issues

  • pingdotgg/t3code#17382: The issue concerns assistant text disappearing or becoming incomplete during timeline updates; this PR changes how streamed rows and timeline rendering are handled.
  • pingdotgg/t3code#22111: The issue concerns losing the live-edge position after a streamed reply; this PR changes timeline rendering and follow behavior.

Possibly related PRs

  • pingdotgg/t3code#20961: Both changes address streamed reply handling in ChatView through quiet thread data and separate live timeline rendering.

Suggested reviewers: t3dotgg, maria-rcks

Merge Risk: ⚪ Minimal · up to 0c8b6

Streaming replies and find results retain their live update paths. No actionable merge-blocking issue remains after normal checks.

Pre-merge checks: Passed 3 · Failed 1

Failed Failed checks: 1 warning

Check nameStatusExplanationResolution
Description checkWarningThe description clearly explains the performance problem, implementation, verification results, measurements, and known test gaps. It does not provide the required scope and approval information, and …Add a Scope and approval section with the triaged issue or explicit maintainer approval, or explain why the change qualifies as an approved focused fix. Add before/after screenshots and a recording if needed to show UI behavior. If an agent…
Passed Passed checks: 3 passed
Check nameStatusExplanation
Title checkPassedThe title clearly and concisely describes the primary performance change: streamed reply text no longer causes ChatView to re-render.
Linked Issues checkPassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes checkPassedCheck skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description clearly explains the performance problem, implementation, verification results, measurements, and known test gaps. It does not provide the required scope and approval information, and it does not include the required before/after screenshots for the UI changes. The agent disclosure also identifies Claude Code but does not state the model and harness.

Resolution

Add a Scope and approval section with the triaged issue or explicit maintainer approval, or explain why the change qualifies as an approved focused fix. Add before/after screenshots and a recording if needed to show UI behavior. If an agent was used, end the description with the model and harness used.

✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

Added 3 commits

  • perf(web): streamed reply text no longer re-renders ChatView6b925e4
  • refactor(web): the quiet thread no longer carries streamed text or timestampsb7c85b7
  • chore(web): keep the thread structure strip helpers private0c8b651