authorgravatar for 206951365+cursor[bot]@users.noreply.github.comcursor[bot] <206951365+cursor[bot]@users.noreply.github.com> 2026-05-05 20:26:38-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-05 20:26:38-07:00
log536dcad19e32c66f24eaa14b9b2189b3dfd444bb
treeb8f9e82d8c69130731ff495665a9dfd31ed76674
parent0e388706470fdf8df7b5b5ae37f12bbeea777e06
signature Signed by PGP key B5690EEEBB952194

Reduce timeline row rerenders (#2498)

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

1 files changed, 246 insertions(+), 209 deletions(-)

apps/web/src/components/chat/MessagesTimeline.tsx+246-209
......@@ -70,11 +70,6 @@ import { formatWorkspaceRelativePath } from "../../filePathDisplay";
7070// ---------------------------------------------------------------------------
7171
7272interface TimelineRowSharedState {
73 activeTurnInProgress: boolean;
74 activeTurnId: TurnId | null | undefined;
75 isWorking: boolean;
76 isRevertingCheckpoint: boolean;
77 completionSummary: string | null;
7873 timestampFormat: TimestampFormat;
7974 routeThreadKey: string;
8075 markdownCwd: string | undefined;
......@@ -86,7 +81,16 @@ interface TimelineRowSharedState {
8681 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
8782}
8883
84interface TimelineRowActivityState {
85 activeTurnInProgress: boolean;
86 activeTurnId: TurnId | null;
87 isWorking: boolean;
88 isRevertingCheckpoint: boolean;
89 completionSummary: string | null;
90}
91
8992const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
93const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!);
9094const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;
9195const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />;
9296
......@@ -191,15 +195,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
191195 };
192196 }, [listRef, onIsAtEndChange, rows.length]);
193197
194 // Memoised context value — only changes on state transitions, NOT on
195 // every streaming chunk. Callbacks from ChatView are useCallback-stable.
196198 const sharedState = useMemo<TimelineRowSharedState>(
197199 () => ({
198 activeTurnInProgress,
199 activeTurnId: activeTurnId ?? null,
200 isWorking,
201 isRevertingCheckpoint,
202 completionSummary,
203200 timestampFormat,
204201 routeThreadKey,
205202 markdownCwd,
......@@ -211,11 +208,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
211208 onOpenTurnDiff,
212209 }),
213210 [
214 activeTurnInProgress,
215 activeTurnId,
216 isWorking,
217 isRevertingCheckpoint,
218 completionSummary,
219211 timestampFormat,
220212 routeThreadKey,
221213 markdownCwd,
......@@ -227,6 +219,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({
227219 onOpenTurnDiff,
228220 ],
229221 );
222 const activityState = useMemo<TimelineRowActivityState>(
223 () => ({
224 activeTurnInProgress,
225 activeTurnId: activeTurnId ?? null,
226 isWorking,
227 isRevertingCheckpoint,
228 completionSummary,
229 }),
230 [activeTurnInProgress, activeTurnId, completionSummary, isRevertingCheckpoint, isWorking],
231 );
230232
231233 // Stable renderItem — no closure deps. Row components read shared state
232234 // from TimelineRowCtx, which propagates through LegendList's memo.
......@@ -251,21 +253,23 @@ export const MessagesTimeline = memo(function MessagesTimeline({
251253
252254 return (
253255 <TimelineRowCtx.Provider value={sharedState}>
254 <LegendList<MessagesTimelineRow>
255 ref={listRef}
256 data={rows}
257 keyExtractor={keyExtractor}
258 renderItem={renderItem}
259 estimatedItemSize={90}
260 initialScrollAtEnd
261 maintainScrollAtEnd
262 maintainScrollAtEndThreshold={0.1}
263 maintainVisibleContentPosition
264 onScroll={handleScroll}
265 className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5"
266 ListHeaderComponent={TIMELINE_LIST_HEADER}
267 ListFooterComponent={TIMELINE_LIST_FOOTER}
268 />
256 <TimelineRowActivityCtx.Provider value={activityState}>
257 <LegendList<MessagesTimelineRow>
258 ref={listRef}
259 data={rows}
260 keyExtractor={keyExtractor}
261 renderItem={renderItem}
262 estimatedItemSize={90}
263 initialScrollAtEnd
264 maintainScrollAtEnd
265 maintainScrollAtEndThreshold={0.1}
266 maintainVisibleContentPosition
267 onScroll={handleScroll}
268 className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5"
269 ListHeaderComponent={TIMELINE_LIST_HEADER}
270 ListFooterComponent={TIMELINE_LIST_FOOTER}
271 />
272 </TimelineRowActivityCtx.Provider>
269273 </TimelineRowCtx.Provider>
270274 );
271275});
......@@ -283,9 +287,7 @@ type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];
283287type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number];
284288type TimelineRow = MessagesTimelineRow;
285289
286function TimelineRowContent({ row }: { row: TimelineRow }) {
287 const ctx = use(TimelineRowCtx);
288
290const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) {
289291 return (
290292 <div
291293 className={cn(
......@@ -297,187 +299,222 @@ function TimelineRowContent({ row }: { row: TimelineRow }) {
297299 data-message-id={row.kind === "message" ? row.message.id : undefined}
298300 data-message-role={row.kind === "message" ? row.message.role : undefined}
299301 >
300 {row.kind === "work" && <WorkGroupSection groupedEntries={row.groupedEntries} />}
301
302 {row.kind === "message" &&
303 row.message.role === "user" &&
304 (() => {
305 const userImages = row.message.attachments ?? [];
306 const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);
307 const terminalContexts = displayedUserMessage.contexts;
308 const canRevertAgentWork = typeof row.revertTurnCount === "number";
309 return (
310 <div className="flex justify-end">
311 <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
312 {userImages.length > 0 && (
313 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
314 {userImages.map(
315 (image: NonNullable<TimelineMessage["attachments"]>[number]) => (
316 <div
317 key={image.id}
318 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
319 >
320 {image.previewUrl ? (
321 <button
322 type="button"
323 className="h-full w-full cursor-zoom-in"
324 aria-label={`Preview ${image.name}`}
325 onClick={() => {
326 const preview = buildExpandedImagePreview(userImages, image.id);
327 if (!preview) return;
328 ctx.onImageExpand(preview);
329 }}
330 >
331 <img
332 src={image.previewUrl}
333 alt={image.name}
334 className="block h-auto max-h-[220px] w-full object-cover"
335 />
336 </button>
337 ) : (
338 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">
339 {image.name}
340 </div>
341 )}
342 </div>
343 ),
344 )}
302 {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null}
303 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}
304 {row.kind === "message" && row.message.role === "assistant" ? (
305 <AssistantTimelineRow row={row} />
306 ) : null}
307 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
308 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
309 </div>
310 );
311});
312
313function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
314 const ctx = use(TimelineRowCtx);
315 const userImages = row.message.attachments ?? [];
316 const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);
317 const terminalContexts = displayedUserMessage.contexts;
318 const canRevertAgentWork = typeof row.revertTurnCount === "number";
319
320 return (
321 <div className="flex justify-end">
322 <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
323 {userImages.length > 0 && (
324 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
325 {userImages.map((image: NonNullable<TimelineMessage["attachments"]>[number]) => (
326 <div
327 key={image.id}
328 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
329 >
330 {image.previewUrl ? (
331 <button
332 type="button"
333 className="h-full w-full cursor-zoom-in"
334 aria-label={`Preview ${image.name}`}
335 onClick={() => {
336 const preview = buildExpandedImagePreview(userImages, image.id);
337 if (!preview) return;
338 ctx.onImageExpand(preview);
339 }}
340 >
341 <img
342 src={image.previewUrl}
343 alt={image.name}
344 className="block h-auto max-h-[220px] w-full object-cover"
345 />
346 </button>
347 ) : (
348 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">
349 {image.name}
345350 </div>
346351 )}
347 {(displayedUserMessage.visibleText.trim().length > 0 ||
348 terminalContexts.length > 0) && (
349 <UserMessageBody
350 text={displayedUserMessage.visibleText}
351 terminalContexts={terminalContexts}
352 />
353 )}
354 <div className="mt-1.5 flex items-center justify-end gap-2">
355 <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
356 {displayedUserMessage.copyText && (
357 <MessageCopyButton text={displayedUserMessage.copyText} />
358 )}
359 {canRevertAgentWork && (
360 <Button
361 type="button"
362 size="xs"
363 variant="outline"
364 disabled={ctx.isRevertingCheckpoint || ctx.isWorking}
365 onClick={() => ctx.onRevertUserMessage(row.message.id)}
366 title="Revert to this message"
367 >
368 <Undo2Icon className="size-3" />
369 </Button>
370 )}
371 </div>
372 <p className="text-right text-xs text-muted-foreground/50">
373 {formatTimestamp(row.message.createdAt, ctx.timestampFormat)}
374 </p>
375 </div>
376352 </div>
377 </div>
378 );
379 })()}
380
381 {row.kind === "message" &&
382 row.message.role === "assistant" &&
383 (() => {
384 const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");
385 const assistantTurnStillInProgress =
386 ctx.activeTurnInProgress &&
387 ctx.activeTurnId !== null &&
388 ctx.activeTurnId !== undefined &&
389 row.message.turnId === ctx.activeTurnId;
390 const assistantCopyState = resolveAssistantMessageCopyState({
391 text: row.message.text ?? null,
392 showCopyButton: row.showAssistantCopyButton,
393 streaming: row.message.streaming || assistantTurnStillInProgress,
394 });
395 return (
396 <>
397 {row.showCompletionDivider && (
398 <div className="my-3 flex items-center gap-3">
399 <span className="h-px flex-1 bg-border" />
400 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
401 {ctx.completionSummary ? `Response • ${ctx.completionSummary}` : "Response"}
402 </span>
403 <span className="h-px flex-1 bg-border" />
404 </div>
405 )}
406 <div className="min-w-0 px-1 py-0.5">
407 <ChatMarkdown
408 text={messageText}
409 cwd={ctx.markdownCwd}
410 isStreaming={Boolean(row.message.streaming)}
411 />
412 <AssistantChangedFilesSection
413 turnSummary={row.assistantTurnDiffSummary}
414 routeThreadKey={ctx.routeThreadKey}
415 resolvedTheme={ctx.resolvedTheme}
416 onOpenTurnDiff={ctx.onOpenTurnDiff}
417 />
418 <div className="mt-1.5 flex items-center gap-2">
419 <p className="text-[10px] text-muted-foreground/30">
420 {row.message.streaming ? (
421 <LiveMessageMeta
422 createdAt={row.message.createdAt}
423 durationStart={row.durationStart}
424 timestampFormat={ctx.timestampFormat}
425 />
426 ) : (
427 formatMessageMeta(
428 row.message.createdAt,
429 formatElapsed(row.durationStart, row.message.completedAt),
430 ctx.timestampFormat,
431 )
432 )}
433 </p>
434 {assistantCopyState.visible ? (
435 <div className="flex items-center opacity-0 transition-opacity duration-200 group-hover/assistant:opacity-100">
436 <MessageCopyButton
437 text={assistantCopyState.text ?? ""}
438 size="icon-xs"
439 variant="outline"
440 className="border-border/50 bg-background/35 text-muted-foreground/45 shadow-none hover:border-border/70 hover:bg-background/55 hover:text-muted-foreground/70"
441 />
442 </div>
443 ) : null}
444 </div>
445 </div>
446 </>
447 );
448 })()}
449
450 {row.kind === "proposed-plan" && (
451 <div className="min-w-0 px-1 py-0.5">
452 <ProposedPlanCard
453 planMarkdown={row.proposedPlan.planMarkdown}
454 environmentId={ctx.activeThreadEnvironmentId}
455 cwd={ctx.markdownCwd}
456 workspaceRoot={ctx.workspaceRoot}
353 ))}
354 </div>
355 )}
356 {(displayedUserMessage.visibleText.trim().length > 0 || terminalContexts.length > 0) && (
357 <UserMessageBody
358 text={displayedUserMessage.visibleText}
359 terminalContexts={terminalContexts}
457360 />
361 )}
362 <div className="mt-1.5 flex items-center justify-end gap-2">
363 <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
364 {displayedUserMessage.copyText && (
365 <MessageCopyButton text={displayedUserMessage.copyText} />
366 )}
367 {canRevertAgentWork && <RevertUserMessageButton messageId={row.message.id} />}
368 </div>
369 <p className="text-right text-xs text-muted-foreground/50">
370 {formatTimestamp(row.message.createdAt, ctx.timestampFormat)}
371 </p>
458372 </div>
459 )}
373 </div>
374 </div>
375 );
376}
460377
461 {row.kind === "working" && (
462 <div className="py-0.5 pl-1.5">
463 <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70">
464 <span className="inline-flex items-center gap-[3px]">
465 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
466 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
467 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
468 </span>
469 <span>
470 {row.createdAt ? (
471 <>
472 Working for <WorkingTimer createdAt={row.createdAt} />
473 </>
474 ) : (
475 "Working..."
476 )}
477 </span>
478 </div>
378function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {
379 const ctx = use(TimelineRowCtx);
380 const activity = use(TimelineRowActivityCtx);
381
382 return (
383 <Button
384 type="button"
385 size="xs"
386 variant="outline"
387 disabled={activity.isRevertingCheckpoint || activity.isWorking}
388 onClick={() => ctx.onRevertUserMessage(messageId)}
389 title="Revert to this message"
390 >
391 <Undo2Icon className="size-3" />
392 </Button>
393 );
394}
395
396function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
397 const ctx = use(TimelineRowCtx);
398 const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");
399
400 return (
401 <>
402 {row.showCompletionDivider && <AssistantCompletionDivider />}
403 <div className="min-w-0 px-1 py-0.5">
404 <ChatMarkdown
405 text={messageText}
406 cwd={ctx.markdownCwd}
407 isStreaming={Boolean(row.message.streaming)}
408 />
409 <AssistantChangedFilesSection
410 turnSummary={row.assistantTurnDiffSummary}
411 routeThreadKey={ctx.routeThreadKey}
412 resolvedTheme={ctx.resolvedTheme}
413 onOpenTurnDiff={ctx.onOpenTurnDiff}
414 />
415 <div className="mt-1.5 flex items-center gap-2">
416 <p className="text-[10px] text-muted-foreground/30">
417 {row.message.streaming ? (
418 <LiveMessageMeta
419 createdAt={row.message.createdAt}
420 durationStart={row.durationStart}
421 timestampFormat={ctx.timestampFormat}
422 />
423 ) : (
424 formatMessageMeta(
425 row.message.createdAt,
426 formatElapsed(row.durationStart, row.message.completedAt),
427 ctx.timestampFormat,
428 )
429 )}
430 </p>
431 <AssistantCopyButton row={row} />
479432 </div>
480 )}
433 </div>
434 </>
435 );
436}
437
438function AssistantCompletionDivider() {
439 const activity = use(TimelineRowActivityCtx);
440
441 return (
442 <div className="my-3 flex items-center gap-3">
443 <span className="h-px flex-1 bg-border" />
444 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
445 {activity.completionSummary ? `Response • ${activity.completionSummary}` : "Response"}
446 </span>
447 <span className="h-px flex-1 bg-border" />
448 </div>
449 );
450}
451
452function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
453 const activity = use(TimelineRowActivityCtx);
454 const assistantTurnStillInProgress =
455 activity.activeTurnInProgress &&
456 activity.activeTurnId !== null &&
457 row.message.turnId === activity.activeTurnId;
458 const assistantCopyState = resolveAssistantMessageCopyState({
459 text: row.message.text ?? null,
460 showCopyButton: row.showAssistantCopyButton,
461 streaming: row.message.streaming || assistantTurnStillInProgress,
462 });
463
464 if (!assistantCopyState.visible) {
465 return null;
466 }
467
468 return (
469 <div className="flex items-center opacity-0 transition-opacity duration-200 group-hover/assistant:opacity-100">
470 <MessageCopyButton
471 text={assistantCopyState.text ?? ""}
472 size="icon-xs"
473 variant="outline"
474 className="border-border/50 bg-background/35 text-muted-foreground/45 shadow-none hover:border-border/70 hover:bg-background/55 hover:text-muted-foreground/70"
475 />
476 </div>
477 );
478}
479
480function ProposedPlanTimelineRow({
481 row,
482}: {
483 row: Extract<TimelineRow, { kind: "proposed-plan" }>;
484}) {
485 const ctx = use(TimelineRowCtx);
486
487 return (
488 <div className="min-w-0 px-1 py-0.5">
489 <ProposedPlanCard
490 planMarkdown={row.proposedPlan.planMarkdown}
491 environmentId={ctx.activeThreadEnvironmentId}
492 cwd={ctx.markdownCwd}
493 workspaceRoot={ctx.workspaceRoot}
494 />
495 </div>
496 );
497}
498
499function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
500 return (
501 <div className="py-0.5 pl-1.5">
502 <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70">
503 <span className="inline-flex items-center gap-[3px]">
504 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
505 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
506 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
507 </span>
508 <span>
509 {row.createdAt ? (
510 <>
511 Working for <WorkingTimer createdAt={row.createdAt} />
512 </>
513 ) : (
514 "Working..."
515 )}
516 </span>
517 </div>
481518 </div>
482519 );
483520}