# perf\(web\): visit marks during a reply no longer re-render ChatView · PR \#23124 · pingdotgg/t3code

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

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

Visibility: public

State: merged

Source repository: pingdotgg/t3code

Source branch: perf/split4-projection-visits

Target branch: perf/split4-projection-selectors

Head commit: 9cdd2360b269fa2045b8e29816d144a69402d2fe

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

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

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

## Description

---

Stacked on #23117.

The open thread gets marked visited several times while a reply streams. Each mark replaced the projection's `thread`, so the quiet thread atom from #23117 passed the change on. ChatView then rendered twice for every mark: once for the projection and once for the shell.

Visit state is read from the shell (`useThreadLifecycle`, `ThreadVisitWatermark`), not from the projection. `ThreadStructure` from #23117 now also drops the thread's `lastVisitedAt` and `updatedAt`, and the quiet atom holds through a `thread` change that only moves those. The same strip function feeds the comparison, a schema equivalence over the rest of the thread, so a rename, mode change or model change still passes through.

Without `lastVisitedAt` in the structure, ChatView's shell-less fallback (a direct link that loads the detail before the shell index) takes it from `lastVisitedAtAtom`, a live binding of just the visit mark. With a shell present it isn't subscribed.

Renders, median of 3 (rc-init.js), on the current heads. #23117 (0c8b6515df) and this layer (9cdd2360b2) ran back to back on the same seeded thread with Codex:

| Interaction | #23117: total | this: total | #23117: ChatView | this: ChatView | #23117: MessagesTimeline | this: MessagesTimeline |
| --- | --- | --- | --- | --- | --- | --- |
| Streamed reply, 15 short lines | 2,390 | 2,367 | 18 | 15 | 22 | 19 |
| Streamed reply, 40 lines | 3,204 | 3,438 | 18 | 17 | 31 | 29 |

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

| Interaction | #23117 | this |
| --- | --- | --- |
| Streamed reply, 15 short lines | 2,678/21/25, 2,390/17/22, 2,293/18/21 | 2,367/15/19, 2,404/16/19, 2,346/15/18 |
| Streamed reply, 40 lines | 3,530/20/34, 3,204/18/31, 3,170/17/30 | 3,666/17/30, 3,438/17/29, 3,425/16/29 |

</details>

ChatView drops by 1–3 renders per reply. A turn gets a few visit marks whatever its length, so the saving does not grow with the reply. Totals swing by about ±10% between runs, because replies vary in length; the long-reply total is within that noise. Typing, thread switch and the right panel don't touch this path.

Tested:
- `threadDetail.test.ts` covers holding through a visit mark, passing a rename through, `lastVisitedAtAtom` following a mark the quiet thread holds through, and the thread's `updatedAt`/`lastVisitedAt` being absent from the structure.
- client-runtime typecheck. Lint and format on the touched files.
- Hands-on in a dev build on this head (9cdd2360b2): a thread I watched while it streamed stayed read in the sidebar. A thread that finished in the background showed "Done", and opening it cleared that. The other checks listed in #23117 (find, Stop, mid-stream switch, draft send, approval, plan mode, reasoning rows during a new turn) were re-run on this head and pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)



