# perf\(web\): streamed reply text no longer re-renders ChatView · PR \#23117 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23117)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

State: merged

Source repository: pingdotgg/t3code

Source branch: perf/split4-projection-selectors

Target branch: main

Head commit: 0c8b6515df8d597e318ee8f4f3d30da07d84eea7

[Discussion and reviews](https://git.cafe/pingdotgg/t3code/pull/23117)

[Commits](https://git.cafe/pingdotgg/t3code/pull/23117/commits?format=markdown)

[Files changed](https://git.cafe/pingdotgg/t3code/pull/23117/diffs?format=markdown)

## Description

---

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:

| Interaction | before: total | after: total | before: ChatView | after: ChatView | before: MessagesTimeline | after: MessagesTimeline |
| --- | --- | --- | --- | --- | --- | --- |
| Streamed reply, 15 short lines | 2,520 | 2,390 | 22 | 18 | 22 | 22 |
| Streamed reply, 40 lines | 3,493 | 3,204 | 32 | 18 | 32 | 31 |
| Thread switch and back | 2,550 | 2,394 | 13 | 10 | 13 | 10 |
| Type 20 chars | 166 | 167 | 1 | 1 | 1 | 1 |
| Right panel open and close | 480 | 484 | 4 | 4 | 4 | 4 |

<details><summary>Raw runs (total / ChatView / MessagesTimeline)</summary>

| Interaction | before | after |
| --- | --- | --- |
| Streamed reply, 15 short lines | 2,564/22/22, 2,355/20/20, 2,520/23/23 | 2,678/21/25, 2,390/17/22, 2,293/18/21 |
| Streamed reply, 40 lines | 4,214/40/40, 3,406/32/32, 3,493/32/32 | 3,530/20/34, 3,204/18/31, 3,170/17/30 |
| Thread switch and back | 3,303/19/19, 2,550/13/13, 2,206/11/11 | 3,274/19/19, 2,394/10/10, 2,132/10/10 |
| Type 20 chars | 166/1/1 ×3 | 167/1/1 ×3 |
| Right panel open and close | 491/4/4, 480/4/4, 480/4/4 | 495/4/4, 484/4/4, 484/4/4 |

</details>

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.

| File | before | after |
| --- | --- | --- |
| ChatView.tsx | 3,896 | 3,908 |
| chat/ThreadTimeline.tsx | | 65 |

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](https://claude.com/claude-code)



