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", () => {
268268 expect(assistantRows[1]?.showCompletionDivider).toBe(true);
269269 });
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
271324 it("projects assistant diff summaries and user revert counts onto the affected rows", () => {
272325 const assistantTurnDiffSummary = {
273326 turnId: "turn-1" as never,
apps/web/src/components/chat/MessagesTimeline.logic.ts+21-4
......@@ -1,7 +1,7 @@
11import * as Equal from "effect/Equal";
22import { type TimelineEntry, type WorkLogEntry } from "../../session-logic";
33import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
4import { type MessageId } from "@t3tools/contracts";
4import { type MessageId, type TurnId } from "@t3tools/contracts";
55
66export const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
77
......@@ -26,7 +26,9 @@ export type MessagesTimelineRow =
2626 message: ChatMessage;
2727 durationStart: string;
2828 showCompletionDivider: boolean;
29 completionSummary: string | null;
2930 showAssistantCopyButton: boolean;
31 assistantCopyStreaming: boolean;
3032 assistantTurnDiffSummary?: TurnDiffSummary | undefined;
3133 revertTurnCount?: number | undefined;
3234 }
......@@ -111,7 +113,10 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<Timeli
111113export function deriveMessagesTimelineRows(input: {
112114 timelineEntries: ReadonlyArray<TimelineEntry>;
113115 completionDividerBeforeEntryId: string | null;
116 completionSummary?: string | null;
114117 isWorking: boolean;
118 activeTurnInProgress?: boolean;
119 activeTurnId?: TurnId | null;
115120 activeTurnStartedAt: string | null;
116121 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;
117122 revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>;
......@@ -157,6 +162,16 @@ export function deriveMessagesTimelineRows(input: {
157162 continue;
158163 }
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
160175 nextRows.push({
161176 kind: "message",
162177 id: timelineEntry.id,
......@@ -164,12 +179,12 @@ export function deriveMessagesTimelineRows(input: {
164179 message: timelineEntry.message,
165180 durationStart:
166181 durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt,
167 showCompletionDivider:
168 timelineEntry.message.role === "assistant" &&
169 input.completionDividerBeforeEntryId === timelineEntry.id,
182 showCompletionDivider,
183 completionSummary: showCompletionDivider ? (input.completionSummary ?? null) : null,
170184 showAssistantCopyButton:
171185 timelineEntry.message.role === "assistant" &&
172186 terminalAssistantMessageIds.has(timelineEntry.message.id),
187 assistantCopyStreaming: timelineEntry.message.streaming || assistantTurnStillInProgress,
173188 assistantTurnDiffSummary:
174189 timelineEntry.message.role === "assistant"
175190 ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id)
......@@ -232,7 +247,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
232247 a.message === bm.message &&
233248 a.durationStart === bm.durationStart &&
234249 a.showCompletionDivider === bm.showCompletionDivider &&
250 a.completionSummary === bm.completionSummary &&
235251 a.showAssistantCopyButton === bm.showAssistantCopyButton &&
252 a.assistantCopyStreaming === bm.assistantCopyStreaming &&
236253 a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary &&
237254 a.revertTurnCount === bm.revertTurnCount
238255 );
apps/web/src/components/chat/MessagesTimeline.tsx+13-18
......@@ -89,11 +89,8 @@ interface TimelineRowSharedState {
8989}
9090
9191interface TimelineRowActivityState {
92 activeTurnInProgress: boolean;
93 activeTurnId: TurnId | null;
9492 isWorking: boolean;
9593 isRevertingCheckpoint: boolean;
96 completionSummary: string | null;
9794}
9895
9996const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
......@@ -164,7 +161,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
164161 deriveMessagesTimelineRows({
165162 timelineEntries,
166163 completionDividerBeforeEntryId,
164 completionSummary,
167165 isWorking,
166 activeTurnInProgress,
167 activeTurnId: activeTurnId ?? null,
168168 activeTurnStartedAt,
169169 turnDiffSummaryByAssistantMessageId,
170170 revertTurnCountByUserMessageId,
......@@ -172,7 +172,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
172172 [
173173 timelineEntries,
174174 completionDividerBeforeEntryId,
175 completionSummary,
175176 isWorking,
177 activeTurnInProgress,
178 activeTurnId,
176179 activeTurnStartedAt,
177180 turnDiffSummaryByAssistantMessageId,
178181 revertTurnCountByUserMessageId,
......@@ -233,13 +236,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
233236 );
234237 const activityState = useMemo<TimelineRowActivityState>(
235238 () => ({
236 activeTurnInProgress,
237 activeTurnId: activeTurnId ?? null,
238239 isWorking,
239240 isRevertingCheckpoint,
240 completionSummary,
241241 }),
242 [activeTurnInProgress, activeTurnId, completionSummary, isRevertingCheckpoint, isWorking],
242 [isRevertingCheckpoint, isWorking],
243243 );
244244
245245 // Stable renderItem — no closure deps. Row components read shared state
......@@ -412,7 +412,9 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
412412
413413 return (
414414 <>
415 {row.showCompletionDivider && <AssistantCompletionDivider />}
415 {row.showCompletionDivider && (
416 <AssistantCompletionDivider completionSummary={row.completionSummary} />
417 )}
416418 <div className="min-w-0 px-1 py-0.5">
417419 <ChatMarkdown
418420 text={messageText}
......@@ -449,14 +451,12 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
449451 );
450452}
451453
452function AssistantCompletionDivider() {
453 const activity = use(TimelineRowActivityCtx);
454
454function AssistantCompletionDivider({ completionSummary }: { completionSummary: string | null }) {
455455 return (
456456 <div className="my-3 flex items-center gap-3">
457457 <span className="h-px flex-1 bg-border" />
458458 <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"}
460460 </span>
461461 <span className="h-px flex-1 bg-border" />
462462 </div>
......@@ -464,15 +464,10 @@ function AssistantCompletionDivider() {
464464}
465465
466466function 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;
472467 const assistantCopyState = resolveAssistantMessageCopyState({
473468 text: row.message.text ?? null,
474469 showCopyButton: row.showAssistantCopyButton,
475 streaming: row.message.streaming || assistantTurnStillInProgress,
470 streaming: row.assistantCopyStreaming,
476471 });
477472
478473 if (!assistantCopyState.visible) {