authorgravatar for 206951365+cursor[bot]@users.noreply.github.comcursor[bot] <206951365+cursor[bot]@users.noreply.github.com> 2026-05-12 20:56:03+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-12 20:56:03+02:00
loga41f4895ce06dadfc1359867344cc4607f944796
treea89125faef80a62537505b3e517f70e5c0a6bce7
parentd15909af15602622c6bd1d10aa2e3e165117a1c8
signature Signed by PGP key B5690EEEBB952194

Reduce chat timeline activity rerenders (#2660)

Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

3 files changed, 87 insertions(+), 22 deletions(-)

apps/web/src/components/chat/MessagesTimeline.logic.test.ts+53
...@@ -268,6 +268,59 @@ describe("deriveMessagesTimelineRows", () => {...@@ -268,6 +268,59 @@ describe("deriveMessagesTimelineRows", () => {
268 expect(assistantRows[1]?.showCompletionDivider).toBe(true);268 expect(assistantRows[1]?.showCompletionDivider).toBe(true);
269 });269 });
270270
271 it("marks only the active assistant turn as streaming for copy controls", () => {
272 const rows = deriveMessagesTimelineRows({
273 timelineEntries: [
274 {
275 id: "assistant-one-entry",
276 kind: "message",
277 createdAt: "2026-01-01T00:00:10Z",
278 message: {
279 id: "assistant-one" as never,
280 role: "assistant",
281 text: "Earlier response.",
282 turnId: "turn-1" as never,
283 createdAt: "2026-01-01T00:00:10Z",
284 completedAt: "2026-01-01T00:00:11Z",
285 streaming: false,
286 },
287 },
288 {
289 id: "assistant-two-entry",
290 kind: "message",
291 createdAt: "2026-01-01T00:00:20Z",
292 message: {
293 id: "assistant-two" as never,
294 role: "assistant",
295 text: "Active response.",
296 turnId: "turn-2" as never,
297 createdAt: "2026-01-01T00:00:20Z",
298 completedAt: "2026-01-01T00:00:30Z",
299 streaming: false,
300 },
301 },
302 ],
303 completionDividerBeforeEntryId: "assistant-two-entry",
304 completionSummary: "done",
305 isWorking: false,
306 activeTurnInProgress: true,
307 activeTurnId: "turn-2" as never,
308 activeTurnStartedAt: null,
309 turnDiffSummaryByAssistantMessageId: new Map(),
310 revertTurnCountByUserMessageId: new Map(),
311 });
312
313 const assistantRows = rows.filter(
314 (row): row is Extract<(typeof rows)[number], { kind: "message" }> =>
315 row.kind === "message" && row.message.role === "assistant",
316 );
317
318 expect(assistantRows[0]?.assistantCopyStreaming).toBe(false);
319 expect(assistantRows[0]?.completionSummary).toBeNull();
320 expect(assistantRows[1]?.assistantCopyStreaming).toBe(true);
321 expect(assistantRows[1]?.completionSummary).toBe("done");
322 });
323
271 it("projects assistant diff summaries and user revert counts onto the affected rows", () => {324 it("projects assistant diff summaries and user revert counts onto the affected rows", () => {
272 const assistantTurnDiffSummary = {325 const assistantTurnDiffSummary = {
273 turnId: "turn-1" as never,326 turnId: "turn-1" as never,
apps/web/src/components/chat/MessagesTimeline.logic.ts+21-4
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import * as Equal from "effect/Equal";1import * as Equal from "effect/Equal";
2import { type TimelineEntry, type WorkLogEntry } from "../../session-logic";2import { type TimelineEntry, type WorkLogEntry } from "../../session-logic";
3import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";3import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
4import { type MessageId } from "@t3tools/contracts";4import { type MessageId, type TurnId } from "@t3tools/contracts";
55
6export const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;6export const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
77
...@@ -26,7 +26,9 @@ export type MessagesTimelineRow =...@@ -26,7 +26,9 @@ export type MessagesTimelineRow =
26 message: ChatMessage;26 message: ChatMessage;
27 durationStart: string;27 durationStart: string;
28 showCompletionDivider: boolean;28 showCompletionDivider: boolean;
29 completionSummary: string | null;
29 showAssistantCopyButton: boolean;30 showAssistantCopyButton: boolean;
31 assistantCopyStreaming: boolean;
30 assistantTurnDiffSummary?: TurnDiffSummary | undefined;32 assistantTurnDiffSummary?: TurnDiffSummary | undefined;
31 revertTurnCount?: number | undefined;33 revertTurnCount?: number | undefined;
32 }34 }
...@@ -111,7 +113,10 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<Timeli...@@ -111,7 +113,10 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<Timeli
111export function deriveMessagesTimelineRows(input: {113export function deriveMessagesTimelineRows(input: {
112 timelineEntries: ReadonlyArray<TimelineEntry>;114 timelineEntries: ReadonlyArray<TimelineEntry>;
113 completionDividerBeforeEntryId: string | null;115 completionDividerBeforeEntryId: string | null;
116 completionSummary?: string | null;
114 isWorking: boolean;117 isWorking: boolean;
118 activeTurnInProgress?: boolean;
119 activeTurnId?: TurnId | null;
115 activeTurnStartedAt: string | null;120 activeTurnStartedAt: string | null;
116 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;121 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;
117 revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>;122 revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>;
...@@ -157,6 +162,16 @@ export function deriveMessagesTimelineRows(input: {...@@ -157,6 +162,16 @@ export function deriveMessagesTimelineRows(input: {
157 continue;162 continue;
158 }163 }
159164
165 const assistantTurnStillInProgress =
166 timelineEntry.message.role === "assistant" &&
167 input.activeTurnInProgress === true &&
168 input.activeTurnId != null &&
169 timelineEntry.message.turnId === input.activeTurnId;
170
171 const showCompletionDivider =
172 timelineEntry.message.role === "assistant" &&
173 input.completionDividerBeforeEntryId === timelineEntry.id;
174
160 nextRows.push({175 nextRows.push({
161 kind: "message",176 kind: "message",
162 id: timelineEntry.id,177 id: timelineEntry.id,
...@@ -164,12 +179,12 @@ export function deriveMessagesTimelineRows(input: {...@@ -164,12 +179,12 @@ export function deriveMessagesTimelineRows(input: {
164 message: timelineEntry.message,179 message: timelineEntry.message,
165 durationStart:180 durationStart:
166 durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt,181 durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt,
167 showCompletionDivider:182 showCompletionDivider,
168 timelineEntry.message.role === "assistant" &&183 completionSummary: showCompletionDivider ? (input.completionSummary ?? null) : null,
169 input.completionDividerBeforeEntryId === timelineEntry.id,
170 showAssistantCopyButton:184 showAssistantCopyButton:
171 timelineEntry.message.role === "assistant" &&185 timelineEntry.message.role === "assistant" &&
172 terminalAssistantMessageIds.has(timelineEntry.message.id),186 terminalAssistantMessageIds.has(timelineEntry.message.id),
187 assistantCopyStreaming: timelineEntry.message.streaming || assistantTurnStillInProgress,
173 assistantTurnDiffSummary:188 assistantTurnDiffSummary:
174 timelineEntry.message.role === "assistant"189 timelineEntry.message.role === "assistant"
175 ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id)190 ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id)
...@@ -232,7 +247,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -232,7 +247,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
232 a.message === bm.message &&247 a.message === bm.message &&
233 a.durationStart === bm.durationStart &&248 a.durationStart === bm.durationStart &&
234 a.showCompletionDivider === bm.showCompletionDivider &&249 a.showCompletionDivider === bm.showCompletionDivider &&
250 a.completionSummary === bm.completionSummary &&
235 a.showAssistantCopyButton === bm.showAssistantCopyButton &&251 a.showAssistantCopyButton === bm.showAssistantCopyButton &&
252 a.assistantCopyStreaming === bm.assistantCopyStreaming &&
236 a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary &&253 a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary &&
237 a.revertTurnCount === bm.revertTurnCount254 a.revertTurnCount === bm.revertTurnCount
238 );255 );
apps/web/src/components/chat/MessagesTimeline.tsx+13-18
...@@ -89,11 +89,8 @@ interface TimelineRowSharedState {...@@ -89,11 +89,8 @@ interface TimelineRowSharedState {
89}89}
9090
91interface TimelineRowActivityState {91interface TimelineRowActivityState {
92 activeTurnInProgress: boolean;
93 activeTurnId: TurnId | null;
94 isWorking: boolean;92 isWorking: boolean;
95 isRevertingCheckpoint: boolean;93 isRevertingCheckpoint: boolean;
96 completionSummary: string | null;
97}94}
9895
99const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);96const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
...@@ -164,7 +161,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -164,7 +161,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
164 deriveMessagesTimelineRows({161 deriveMessagesTimelineRows({
165 timelineEntries,162 timelineEntries,
166 completionDividerBeforeEntryId,163 completionDividerBeforeEntryId,
164 completionSummary,
167 isWorking,165 isWorking,
166 activeTurnInProgress,
167 activeTurnId: activeTurnId ?? null,
168 activeTurnStartedAt,168 activeTurnStartedAt,
169 turnDiffSummaryByAssistantMessageId,169 turnDiffSummaryByAssistantMessageId,
170 revertTurnCountByUserMessageId,170 revertTurnCountByUserMessageId,
...@@ -172,7 +172,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -172,7 +172,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
172 [172 [
173 timelineEntries,173 timelineEntries,
174 completionDividerBeforeEntryId,174 completionDividerBeforeEntryId,
175 completionSummary,
175 isWorking,176 isWorking,
177 activeTurnInProgress,
178 activeTurnId,
176 activeTurnStartedAt,179 activeTurnStartedAt,
177 turnDiffSummaryByAssistantMessageId,180 turnDiffSummaryByAssistantMessageId,
178 revertTurnCountByUserMessageId,181 revertTurnCountByUserMessageId,
...@@ -233,13 +236,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -233,13 +236,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
233 );236 );
234 const activityState = useMemo<TimelineRowActivityState>(237 const activityState = useMemo<TimelineRowActivityState>(
235 () => ({238 () => ({
236 activeTurnInProgress,
237 activeTurnId: activeTurnId ?? null,
238 isWorking,239 isWorking,
239 isRevertingCheckpoint,240 isRevertingCheckpoint,
240 completionSummary,
241 }),241 }),
242 [activeTurnInProgress, activeTurnId, completionSummary, isRevertingCheckpoint, isWorking],242 [isRevertingCheckpoint, isWorking],
243 );243 );
244244
245 // Stable renderItem — no closure deps. Row components read shared state245 // Stable renderItem — no closure deps. Row components read shared state
...@@ -412,7 +412,9 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess...@@ -412,7 +412,9 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
412412
413 return (413 return (
414 <>414 <>
415 {row.showCompletionDivider && <AssistantCompletionDivider />}415 {row.showCompletionDivider && (
416 <AssistantCompletionDivider completionSummary={row.completionSummary} />
417 )}
416 <div className="min-w-0 px-1 py-0.5">418 <div className="min-w-0 px-1 py-0.5">
417 <ChatMarkdown419 <ChatMarkdown
418 text={messageText}420 text={messageText}
...@@ -449,14 +451,12 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess...@@ -449,14 +451,12 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
449 );451 );
450}452}
451453
452function AssistantCompletionDivider() {454function AssistantCompletionDivider({ completionSummary }: { completionSummary: string | null }) {
453 const activity = use(TimelineRowActivityCtx);
454
455 return (455 return (
456 <div className="my-3 flex items-center gap-3">456 <div className="my-3 flex items-center gap-3">
457 <span className="h-px flex-1 bg-border" />457 <span className="h-px flex-1 bg-border" />
458 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">458 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
459 {activity.completionSummary ? `Response • ${activity.completionSummary}` : "Response"}459 {completionSummary ? `Response • ${completionSummary}` : "Response"}
460 </span>460 </span>
461 <span className="h-px flex-1 bg-border" />461 <span className="h-px flex-1 bg-border" />
462 </div>462 </div>
...@@ -464,15 +464,10 @@ function AssistantCompletionDivider() {...@@ -464,15 +464,10 @@ function AssistantCompletionDivider() {
464}464}
465465
466function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {466function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
467 const activity = use(TimelineRowActivityCtx);
468 const assistantTurnStillInProgress =
469 activity.activeTurnInProgress &&
470 activity.activeTurnId !== null &&
471 row.message.turnId === activity.activeTurnId;
472 const assistantCopyState = resolveAssistantMessageCopyState({467 const assistantCopyState = resolveAssistantMessageCopyState({
473 text: row.message.text ?? null,468 text: row.message.text ?? null,
474 showCopyButton: row.showAssistantCopyButton,469 showCopyButton: row.showAssistantCopyButton,
475 streaming: row.message.streaming || assistantTurnStillInProgress,470 streaming: row.assistantCopyStreaming,
476 });471 });
477472
478 if (!assistantCopyState.visible) {473 if (!assistantCopyState.visible) {