History

Reduce timeline row rerenders (#2498)

cursor[bot] committed 6 months ago

Commit 536dcad1 parent 0e388701 file · +246 −209Browse files

Unified · Split

apps/web/src/components/chat/MessagesTimeline.tsx+246−209
@@ -70,11 +70,6 @@ import { formatWorkspaceRelativePath } from "../../filePathDisplay"; · 69 unmodified lines
7070 // ---------------------------------------------------------------------------
7171
7272 interface 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 { · 5 unmodified lines
8681 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
8782 }
8883
84+interface TimelineRowActivityState {
85+ activeTurnInProgress: boolean;
86+ activeTurnId: TurnId | null;
87+ isWorking: boolean;
88+ isRevertingCheckpoint: boolean;
89+ completionSummary: string | null;
90+}
91+
8992 const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
93+const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!);
9094 const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;
9195 const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />;
9296
@@ -191,15 +195,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 98 unmodified lines
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({ · 5 unmodified lines
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({ · 5 unmodified lines
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({ · 18 unmodified lines
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"]; · 11 unmodified lines
283287 type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number];
284288 type TimelineRow = MessagesTimelineRow;
285289
286−function TimelineRowContent({ row }: { row: TimelineRow }) {
287− const ctx = use(TimelineRowCtx);
288−
290+const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) {
289291 return (
290292 <div
291293 className={cn(
@@ -297,187 +299,222 @@ function TimelineRowContent({ row }: { row: TimelineRow }) { · 5 unmodified lines
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+
313+function 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>
378+function 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+
396+function 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+
438+function 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+
452+function 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+
480+function 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+
499+function 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 }