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";...@@ -70,11 +70,6 @@ import { formatWorkspaceRelativePath } from "../../filePathDisplay";
70// ---------------------------------------------------------------------------70// ---------------------------------------------------------------------------
7171
72interface TimelineRowSharedState {72interface TimelineRowSharedState {
73 activeTurnInProgress: boolean;
74 activeTurnId: TurnId | null | undefined;
75 isWorking: boolean;
76 isRevertingCheckpoint: boolean;
77 completionSummary: string | null;
78 timestampFormat: TimestampFormat;73 timestampFormat: TimestampFormat;
79 routeThreadKey: string;74 routeThreadKey: string;
80 markdownCwd: string | undefined;75 markdownCwd: string | undefined;
...@@ -86,7 +81,16 @@ interface TimelineRowSharedState {...@@ -86,7 +81,16 @@ interface TimelineRowSharedState {
86 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;81 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
87}82}
8883
84interface TimelineRowActivityState {
85 activeTurnInProgress: boolean;
86 activeTurnId: TurnId | null;
87 isWorking: boolean;
88 isRevertingCheckpoint: boolean;
89 completionSummary: string | null;
90}
91
89const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);92const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
93const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!);
90const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;94const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;
91const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />;95const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />;
9296
...@@ -191,15 +195,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -191,15 +195,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
191 };195 };
192 }, [listRef, onIsAtEndChange, rows.length]);196 }, [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.
196 const sharedState = useMemo<TimelineRowSharedState>(198 const sharedState = useMemo<TimelineRowSharedState>(
197 () => ({199 () => ({
198 activeTurnInProgress,
199 activeTurnId: activeTurnId ?? null,
200 isWorking,
201 isRevertingCheckpoint,
202 completionSummary,
203 timestampFormat,200 timestampFormat,
204 routeThreadKey,201 routeThreadKey,
205 markdownCwd,202 markdownCwd,
...@@ -211,11 +208,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -211,11 +208,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
211 onOpenTurnDiff,208 onOpenTurnDiff,
212 }),209 }),
213 [210 [
214 activeTurnInProgress,
215 activeTurnId,
216 isWorking,
217 isRevertingCheckpoint,
218 completionSummary,
219 timestampFormat,211 timestampFormat,
220 routeThreadKey,212 routeThreadKey,
221 markdownCwd,213 markdownCwd,
...@@ -227,6 +219,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -227,6 +219,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({
227 onOpenTurnDiff,219 onOpenTurnDiff,
228 ],220 ],
229 );221 );
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
231 // Stable renderItem — no closure deps. Row components read shared state233 // Stable renderItem — no closure deps. Row components read shared state
232 // from TimelineRowCtx, which propagates through LegendList's memo.234 // from TimelineRowCtx, which propagates through LegendList's memo.
...@@ -251,21 +253,23 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -251,21 +253,23 @@ export const MessagesTimeline = memo(function MessagesTimeline({
251253
252 return (254 return (
253 <TimelineRowCtx.Provider value={sharedState}>255 <TimelineRowCtx.Provider value={sharedState}>
254 <LegendList<MessagesTimelineRow>256 <TimelineRowActivityCtx.Provider value={activityState}>
255 ref={listRef}257 <LegendList<MessagesTimelineRow>
256 data={rows}258 ref={listRef}
257 keyExtractor={keyExtractor}259 data={rows}
258 renderItem={renderItem}260 keyExtractor={keyExtractor}
259 estimatedItemSize={90}261 renderItem={renderItem}
260 initialScrollAtEnd262 estimatedItemSize={90}
261 maintainScrollAtEnd263 initialScrollAtEnd
262 maintainScrollAtEndThreshold={0.1}264 maintainScrollAtEnd
263 maintainVisibleContentPosition265 maintainScrollAtEndThreshold={0.1}
264 onScroll={handleScroll}266 maintainVisibleContentPosition
265 className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5"267 onScroll={handleScroll}
266 ListHeaderComponent={TIMELINE_LIST_HEADER}268 className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5"
267 ListFooterComponent={TIMELINE_LIST_FOOTER}269 ListHeaderComponent={TIMELINE_LIST_HEADER}
268 />270 ListFooterComponent={TIMELINE_LIST_FOOTER}
271 />
272 </TimelineRowActivityCtx.Provider>
269 </TimelineRowCtx.Provider>273 </TimelineRowCtx.Provider>
270 );274 );
271});275});
...@@ -283,9 +287,7 @@ type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];...@@ -283,9 +287,7 @@ type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];
283type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number];287type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number];
284type TimelineRow = MessagesTimelineRow;288type TimelineRow = MessagesTimelineRow;
285289
286function TimelineRowContent({ row }: { row: TimelineRow }) {290const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) {
287 const ctx = use(TimelineRowCtx);
288
289 return (291 return (
290 <div292 <div
291 className={cn(293 className={cn(
...@@ -297,187 +299,222 @@ function TimelineRowContent({ row }: { row: TimelineRow }) {...@@ -297,187 +299,222 @@ function TimelineRowContent({ row }: { row: TimelineRow }) {
297 data-message-id={row.kind === "message" ? row.message.id : undefined}299 data-message-id={row.kind === "message" ? row.message.id : undefined}
298 data-message-role={row.kind === "message" ? row.message.role : undefined}300 data-message-role={row.kind === "message" ? row.message.role : undefined}
299 >301 >
300 {row.kind === "work" && <WorkGroupSection groupedEntries={row.groupedEntries} />}302 {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null}
301303 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}
302 {row.kind === "message" &&304 {row.kind === "message" && row.message.role === "assistant" ? (
303 row.message.role === "user" &&305 <AssistantTimelineRow row={row} />
304 (() => {306 ) : null}
305 const userImages = row.message.attachments ?? [];307 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
306 const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);308 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
307 const terminalContexts = displayedUserMessage.contexts;309 </div>
308 const canRevertAgentWork = typeof row.revertTurnCount === "number";310 );
309 return (311});
310 <div className="flex justify-end">312
311 <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">313function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
312 {userImages.length > 0 && (314 const ctx = use(TimelineRowCtx);
313 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">315 const userImages = row.message.attachments ?? [];
314 {userImages.map(316 const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);
315 (image: NonNullable<TimelineMessage["attachments"]>[number]) => (317 const terminalContexts = displayedUserMessage.contexts;
316 <div318 const canRevertAgentWork = typeof row.revertTurnCount === "number";
317 key={image.id}319
318 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"320 return (
319 >321 <div className="flex justify-end">
320 {image.previewUrl ? (322 <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
321 <button323 {userImages.length > 0 && (
322 type="button"324 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
323 className="h-full w-full cursor-zoom-in"325 {userImages.map((image: NonNullable<TimelineMessage["attachments"]>[number]) => (
324 aria-label={`Preview ${image.name}`}326 <div
325 onClick={() => {327 key={image.id}
326 const preview = buildExpandedImagePreview(userImages, image.id);328 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
327 if (!preview) return;329 >
328 ctx.onImageExpand(preview);330 {image.previewUrl ? (
329 }}331 <button
330 >332 type="button"
331 <img333 className="h-full w-full cursor-zoom-in"
332 src={image.previewUrl}334 aria-label={`Preview ${image.name}`}
333 alt={image.name}335 onClick={() => {
334 className="block h-auto max-h-[220px] w-full object-cover"336 const preview = buildExpandedImagePreview(userImages, image.id);
335 />337 if (!preview) return;
336 </button>338 ctx.onImageExpand(preview);
337 ) : (339 }}
338 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">340 >
339 {image.name}341 <img
340 </div>342 src={image.previewUrl}
341 )}343 alt={image.name}
342 </div>344 className="block h-auto max-h-[220px] w-full object-cover"
343 ),345 />
344 )}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}
345 </div>350 </div>
346 )}351 )}
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>
376 </div>352 </div>
377 </div>353 ))}
378 );354 </div>
379 })()}355 )}
380356 {(displayedUserMessage.visibleText.trim().length > 0 || terminalContexts.length > 0) && (
381 {row.kind === "message" &&357 <UserMessageBody
382 row.message.role === "assistant" &&358 text={displayedUserMessage.visibleText}
383 (() => {359 terminalContexts={terminalContexts}
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}
457 />360 />
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>
458 </div>372 </div>
459 )}373 </div>
374 </div>
375 );
376}
460377
461 {row.kind === "working" && (378function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {
462 <div className="py-0.5 pl-1.5">379 const ctx = use(TimelineRowCtx);
463 <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70">380 const activity = use(TimelineRowActivityCtx);
464 <span className="inline-flex items-center gap-[3px]">381
465 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />382 return (
466 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />383 <Button
467 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />384 type="button"
468 </span>385 size="xs"
469 <span>386 variant="outline"
470 {row.createdAt ? (387 disabled={activity.isRevertingCheckpoint || activity.isWorking}
471 <>388 onClick={() => ctx.onRevertUserMessage(messageId)}
472 Working for <WorkingTimer createdAt={row.createdAt} />389 title="Revert to this message"
473 </>390 >
474 ) : (391 <Undo2Icon className="size-3" />
475 "Working..."392 </Button>
476 )}393 );
477 </span>394}
478 </div>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} />
479 </div>432 </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>
481 </div>518 </div>
482 );519 );
483}520}