History

web: keep active turn out of virtualization

Cursor Agent committed 8 months ago

Commit 1a33f251 parent 21c823e1 file · +336 −285Browse files

Unified · Split

apps/web/src/components/ChatView.tsx+336−285
@@ -1824,6 +1824,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1823 unmodified lines
18241824 <MessagesTimeline
18251825 hasMessages={timelineMessages.length > 0}
18261826 isWorking={isWorking}
1827+ activeTurnInProgress={!latestTurnSettled}
1828+ activeTurnStartedAt={activeLatestTurn?.startedAt ?? null}
18271829 scrollContainerRef={messagesScrollRef}
18281830 timelineEntries={timelineEntries}
18291831 completionDividerBeforeEntryId={completionDividerBeforeEntryId}
@@ -2351,6 +2353,8 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri · 521 unmodified lines
23512353 interface MessagesTimelineProps {
23522354 hasMessages: boolean;
23532355 isWorking: boolean;
2356+ activeTurnInProgress: boolean;
2357+ activeTurnStartedAt: string | null;
23542358 scrollContainerRef: React.RefObject<HTMLDivElement | null>;
23552359 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
23562360 completionDividerBeforeEntryId: string | null;
@@ -2374,19 +2378,23 @@ type TimelineRow = · 17 unmodified lines
23742378 | {
23752379 kind: "work";
23762380 id: string;
2381+ createdAt: string;
23772382 groupedEntries: TimelineWorkEntry[];
23782383 }
23792384 | {
23802385 kind: "message";
23812386 id: string;
2387+ createdAt: string;
23822388 message: TimelineMessage;
23832389 showCompletionDivider: boolean;
23842390 }
2385− | { kind: "working"; id: string };
2391+ | { kind: "working"; id: string; createdAt: string | null };
23862392
23872393 const MessagesTimeline = memo(function MessagesTimeline({
23882394 hasMessages,
23892395 isWorking,
2396+ activeTurnInProgress,
2397+ activeTurnStartedAt,
23902398 scrollContainerRef,
23912399 timelineEntries,
23922400 completionDividerBeforeEntryId,
@@ -2423,6 +2431,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 30 unmodified lines
24232431 nextRows.push({
24242432 kind: "work",
24252433 id: timelineEntry.id,
2434+ createdAt: timelineEntry.createdAt,
24262435 groupedEntries,
24272436 });
24282437 index = cursor - 1;
@@ -2432,6 +2441,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 3 unmodified lines
24322441 nextRows.push({
24332442 kind: "message",
24342443 id: timelineEntry.id,
2444+ createdAt: timelineEntry.createdAt,
24352445 message: timelineEntry.message,
24362446 showCompletionDivider:
24372447 timelineEntry.message.role === "assistant" &&
@@ -2440,14 +2450,59 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 2 unmodified lines
24402450 }
24412451
24422452 if (isWorking) {
2443− nextRows.push({ kind: "working", id: "working-indicator-row" });
2453+ nextRows.push({
2454+ kind: "working",
2455+ id: "working-indicator-row",
2456+ createdAt: activeTurnStartedAt,
2457+ });
24442458 }
24452459
24462460 return nextRows;
2447− }, [timelineEntries, completionDividerBeforeEntryId, isWorking]);
2461+ }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]);
2462+
2463+ const firstUnvirtualizedRowIndex = useMemo(() => {
2464+ if (!activeTurnInProgress) return rows.length;
2465+
2466+ const turnStartedAtMs =
2467+ typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN;
2468+ let firstCurrentTurnRowIndex = -1;
2469+ if (!Number.isNaN(turnStartedAtMs)) {
2470+ firstCurrentTurnRowIndex = rows.findIndex((row) => {
2471+ if (row.kind === "working") return true;
2472+ if (!row.createdAt) return false;
2473+ const rowCreatedAtMs = Date.parse(row.createdAt);
2474+ return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs;
2475+ });
2476+ }
2477+
2478+ if (firstCurrentTurnRowIndex < 0) {
2479+ firstCurrentTurnRowIndex = rows.findIndex(
2480+ (row) => row.kind === "message" && row.message.streaming,
2481+ );
2482+ }
2483+
2484+ if (firstCurrentTurnRowIndex < 0) {
2485+ return rows.length;
2486+ }
2487+
2488+ for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) {
2489+ const previousRow = rows[index];
2490+ if (!previousRow || previousRow.kind !== "message") continue;
2491+ if (previousRow.message.role === "user") {
2492+ return index;
2493+ }
2494+ if (previousRow.message.role === "assistant" && !previousRow.message.streaming) {
2495+ break;
2496+ }
2497+ }
2498+
2499+ return firstCurrentTurnRowIndex;
2500+ }, [activeTurnInProgress, activeTurnStartedAt, rows]);
2501+
2502+ const virtualizedRowCount = Math.max(0, Math.min(rows.length, firstUnvirtualizedRowIndex));
24482503
24492504 const rowVirtualizer = useVirtualizer({
2450− count: rows.length,
2505+ count: virtualizedRowCount,
24512506 getScrollElement: () => scrollContainerRef.current,
24522507 estimateSize: (index: number) => {
24532508 const row = rows[index];
@@ -2461,303 +2516,299 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 7 unmodified lines
24612516 });
24622517
24632518 const virtualRows = rowVirtualizer.getVirtualItems();
2464−
2465− if (!hasMessages && !isWorking) {
2466− return (
2467− <div className="flex h-full items-center justify-center">
2468− <p className="text-sm text-muted-foreground/30">
2469− Send a message to start the conversation.
2470− </p>
2471− </div>
2472− );
2473− }
2474−
2475− return (
2476− <div
2477− className="relative mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden"
2478− style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
2479− >
2480− {virtualRows.map((virtualRow: VirtualItem) => {
2481− const row = rows[virtualRow.index];
2482− if (!row) return null;
2483−
2484− return (
2485− <div
2486− key={row.id}
2487− data-index={virtualRow.index}
2488− ref={rowVirtualizer.measureElement}
2489− className="absolute left-0 top-0 w-full"
2490− style={{ transform: `translateY(${virtualRow.start}px)` }}
2491− >
2492− <div className="pb-4">
2493− {row.kind === "work" &&
2494− (() => {
2495− const groupId = row.id;
2496− const groupedEntries = row.groupedEntries;
2497− const isExpanded = expandedWorkGroups[groupId] ?? false;
2498− const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
2499− const visibleEntries =
2500− hasOverflow && !isExpanded
2501− ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
2502− : groupedEntries;
2503− const hiddenCount = groupedEntries.length - visibleEntries.length;
2504− const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
2505− const groupLabel = onlyToolEntries
2506− ? groupedEntries.length === 1
2507− ? "Tool call"
2508− : `Tool calls (${groupedEntries.length})`
2509− : groupedEntries.length === 1
2510− ? "Work event"
2511− : `Work log (${groupedEntries.length})`;
2512−
2519+ const nonVirtualizedRows = rows.slice(virtualizedRowCount);
2520+
2521+ const renderRowContent = (row: TimelineRow) => (
2522+ <div className="pb-4">
2523+ {row.kind === "work" &&
2524+ (() => {
2525+ const groupId = row.id;
2526+ const groupedEntries = row.groupedEntries;
2527+ const isExpanded = expandedWorkGroups[groupId] ?? false;
2528+ const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
2529+ const visibleEntries =
2530+ hasOverflow && !isExpanded
2531+ ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
2532+ : groupedEntries;
2533+ const hiddenCount = groupedEntries.length - visibleEntries.length;
2534+ const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
2535+ const groupLabel = onlyToolEntries
2536+ ? groupedEntries.length === 1
2537+ ? "Tool call"
2538+ : `Tool calls (${groupedEntries.length})`
2539+ : groupedEntries.length === 1
2540+ ? "Work event"
2541+ : `Work log (${groupedEntries.length})`;
2542+
2543+ return (
2544+ <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">
2545+ <div className="mb-1.5 flex items-center justify-between gap-3">
2546+ <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
2547+ {groupLabel}
2548+ </p>
2549+ {hasOverflow && (
2550+ <button
2551+ type="button"
2552+ className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"
2553+ onClick={() => onToggleWorkGroup(groupId)}
2554+ >
2555+ {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
2556+ </button>
2557+ )}
2558+ </div>
2559+ <div className="space-y-1">
2560+ {visibleEntries.map((workEntry) => (
2561+ <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5">
2562+ <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
2563+ <p
2564+ className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}
2565+ >
2566+ {workEntry.detail ? (
2567+ <>
2568+ {workEntry.label}
2569+ <span
2570+ className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60"
2571+ title={workEntry.detail}
2572+ >
2573+ {workEntry.detail}
2574+ </span>
2575+ </>
2576+ ) : (
2577+ workEntry.label
2578+ )}
2579+ </p>
2580+ </div>
2581+ ))}
2582+ </div>
2583+ </div>
2584+ );
2585+ })()}
2586+
2587+ {row.kind === "message" &&
2588+ row.message.role === "user" &&
2589+ (() => {
2590+ const userImages = row.message.attachments ?? [];
2591+ const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id);
2592+ return (
2593+ <div className="flex justify-end">
2594+ <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
2595+ {userImages.length > 0 && (
2596+ <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
2597+ {userImages.map((image: NonNullable<TimelineMessage["attachments"]>[number]) => (
2598+ <div
2599+ key={image.id}
2600+ className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
2601+ >
2602+ {image.previewUrl ? (
2603+ <img
2604+ src={image.previewUrl}
2605+ alt={image.name}
2606+ className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
2607+ onClick={() => onImageExpand({ src: image.previewUrl!, name: image.name })}
2608+ />
2609+ ) : (
2610+ <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">
2611+ {image.name}
2612+ </div>
2613+ )}
2614+ </div>
2615+ ))}
2616+ </div>
2617+ )}
2618+ {row.message.text && (
2619+ <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground">
2620+ {row.message.text}
2621+ </pre>
2622+ )}
2623+ <div className="mt-1.5 flex items-center justify-end gap-2">
2624+ <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
2625+ {row.message.text && <MessageCopyButton text={row.message.text} />}
2626+ {canRevertAgentWork && (
2627+ <Button
2628+ type="button"
2629+ size="xs"
2630+ variant="outline"
2631+ disabled={isRevertingCheckpoint || isWorking}
2632+ onClick={() => onRevertUserMessage(row.message.id)}
2633+ title="Revert to this message"
2634+ >
2635+ <Undo2Icon className="size-3" />
2636+ </Button>
2637+ )}
2638+ </div>
2639+ <p className="text-right text-[10px] text-muted-foreground/30">
2640+ {formatTimestamp(row.message.createdAt)}
2641+ </p>
2642+ </div>
2643+ </div>
2644+ </div>
2645+ );
2646+ })()}
2647+
2648+ {row.kind === "message" &&
2649+ row.message.role === "assistant" &&
2650+ (() => {
2651+ const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");
2652+ return (
2653+ <>
2654+ {row.showCompletionDivider && (
2655+ <div className="my-3 flex items-center gap-3">
2656+ <span className="h-px flex-1 bg-border" />
2657+ <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
2658+ {completionSummary ? `Response • ${completionSummary}` : "Response"}
2659+ </span>
2660+ <span className="h-px flex-1 bg-border" />
2661+ </div>
2662+ )}
2663+ <div className="min-w-0 px-1 py-0.5">
2664+ <ChatMarkdown text={messageText} cwd={markdownCwd} />
2665+ {(() => {
2666+ const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id);
2667+ if (!turnSummary) return null;
2668+ const checkpointFiles = turnSummary.files;
2669+ if (checkpointFiles.length === 0) return null;
2670+ const summaryStat = checkpointFiles.reduce(
2671+ (acc, file) => {
2672+ if (
2673+ typeof file.additions !== "number" ||
2674+ typeof file.deletions !== "number"
2675+ ) {
2676+ return acc;
2677+ }
2678+ return {
2679+ additions: acc.additions + file.additions,
2680+ deletions: acc.deletions + file.deletions,
2681+ };
2682+ },
2683+ { additions: 0, deletions: 0 },
2684+ );
2685+ const changedFileCountLabel = String(checkpointFiles.length);
25132686 return (
2514− <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">
2515− <div className="mb-1.5 flex items-center justify-between gap-3">
2687+ <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
2688+ <div className="mb-1.5 flex items-center justify-between gap-2">
25162689 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
2517− {groupLabel}
2690+ <span>Changed files ({changedFileCountLabel})</span>
2691+ {(summaryStat.additions > 0 || summaryStat.deletions > 0) && (
2692+ <>
2693+ <span className="mx-1">•</span>
2694+ <span className="text-success">+{summaryStat.additions}</span>
2695+ <span className="mx-0.5 text-muted-foreground/70">/</span>
2696+ <span className="text-destructive">-{summaryStat.deletions}</span>
2697+ </>
2698+ )}
25182699 </p>
2519− {hasOverflow && (
2700+ <Button
2701+ type="button"
2702+ size="xs"
2703+ variant="outline"
2704+ onClick={() =>
2705+ onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)
2706+ }
2707+ >
2708+ View diff
2709+ </Button>
2710+ </div>
2711+ <div className="flex flex-wrap gap-1.5">
2712+ {checkpointFiles.map((file) => (
25202713 <button
2714+ key={`${turnSummary.turnId}:${file.path}`}
25212715 type="button"
2522− className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"
2523− onClick={() => onToggleWorkGroup(groupId)}
2716+ className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90"
2717+ onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)}
25242718 >
2525− {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
2526− </button>
2527− )}
2528− </div>
2529− <div className="space-y-1">
2530− {visibleEntries.map((workEntry) => (
2531− <div
2532− key={`work-row:${workEntry.id}`}
2533− className="flex items-start gap-2 py-0.5"
2534− >
2535− <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
2536− <p
2537− className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}
2538− >
2539− {workEntry.detail ? (
2719+ {(() => {
2720+ const stat =
2721+ typeof file.additions === "number" &&
2722+ typeof file.deletions === "number"
2723+ ? {
2724+ additions: file.additions,
2725+ deletions: file.deletions,
2726+ }
2727+ : null;
2728+ if (!stat) {
2729+ return file.path;
2730+ }
2731+ return (
25402732 <>
2541− {workEntry.label}
2542− <span
2543− className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60"
2544− title={workEntry.detail}
2545− >
2546− {workEntry.detail}
2547− </span>
2733+ <span>{file.path}</span>
2734+ <span className="ml-1 text-muted-foreground/70">(</span>
2735+ <span className="text-success">+{stat.additions}</span>
2736+ <span className="mx-0.5 text-muted-foreground/70">/</span>
2737+ <span className="text-destructive">-{stat.deletions}</span>
2738+ <span className="text-muted-foreground/70">)</span>
25482739 </>
2549− ) : (
2550− workEntry.label
2551− )}
2552− </p>
2553− </div>
2740+ );
2741+ })()}
2742+ </button>
25542743 ))}
25552744 </div>
25562745 </div>
25572746 );
25582747 })()}
2748+ <p className="mt-1.5 text-[10px] text-muted-foreground/30">
2749+ {formatMessageMeta(
2750+ row.message.createdAt,
2751+ row.message.streaming
2752+ ? formatElapsed(row.message.createdAt, nowIso)
2753+ : formatElapsed(row.message.createdAt, row.message.completedAt),
2754+ )}
2755+ </p>
2756+ </div>
2757+ </>
2758+ );
2759+ })()}
2760+
2761+ {row.kind === "working" && (
2762+ <div className="flex items-center gap-2 py-0.5 pl-1.5">
2763+ <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
2764+ <div className="flex items-center pt-1">
2765+ <span className="inline-flex items-center gap-[3px]">
2766+ <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
2767+ <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
2768+ <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
2769+ </span>
2770+ </div>
2771+ </div>
2772+ )}
2773+ </div>
2774+ );
25592775
2560− {row.kind === "message" &&
2561− row.message.role === "user" &&
2562− (() => {
2563− const userImages = row.message.attachments ?? [];
2564− const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id);
2565− return (
2566− <div className="flex justify-end">
2567− <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
2568− {userImages.length > 0 && (
2569− <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
2570− {userImages.map(
2571− (image: NonNullable<TimelineMessage["attachments"]>[number]) => (
2572− <div
2573− key={image.id}
2574− className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
2575− >
2576− {image.previewUrl ? (
2577− <img
2578− src={image.previewUrl}
2579− alt={image.name}
2580− className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
2581− onClick={() =>
2582− onImageExpand({ src: image.previewUrl!, name: image.name })
2583− }
2584− />
2585− ) : (
2586− <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">
2587− {image.name}
2588− </div>
2589− )}
2590− </div>
2591− ),
2592− )}
2593− </div>
2594− )}
2595− {row.message.text && (
2596− <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground">
2597− {row.message.text}
2598− </pre>
2599− )}
2600− <div className="mt-1.5 flex items-center justify-end gap-2">
2601− <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
2602− {row.message.text && <MessageCopyButton text={row.message.text} />}
2603− {canRevertAgentWork && (
2604− <Button
2605− type="button"
2606− size="xs"
2607− variant="outline"
2608− disabled={isRevertingCheckpoint || isWorking}
2609− onClick={() => onRevertUserMessage(row.message.id)}
2610− title="Revert to this message"
2611− >
2612− <Undo2Icon className="size-3" />
2613− </Button>
2614− )}
2615− </div>
2616− <p className="text-right text-[10px] text-muted-foreground/30">
2617− {formatTimestamp(row.message.createdAt)}
2618− </p>
2619− </div>
2620− </div>
2621− </div>
2622− );
2623− })()}
2776+ if (!hasMessages && !isWorking) {
2777+ return (
2778+ <div className="flex h-full items-center justify-center">
2779+ <p className="text-sm text-muted-foreground/30">
2780+ Send a message to start the conversation.
2781+ </p>
2782+ </div>
2783+ );
2784+ }
26242785
2625− {row.kind === "message" &&
2626− row.message.role === "assistant" &&
2627− (() => {
2628− const messageText =
2629− row.message.text || (row.message.streaming ? "" : "(empty response)");
2630− return (
2631− <>
2632− {row.showCompletionDivider && (
2633− <div className="my-3 flex items-center gap-3">
2634− <span className="h-px flex-1 bg-border" />
2635− <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
2636− {completionSummary ? `Response • ${completionSummary}` : "Response"}
2637− </span>
2638− <span className="h-px flex-1 bg-border" />
2639− </div>
2640− )}
2641− <div className="min-w-0 px-1 py-0.5">
2642− <ChatMarkdown text={messageText} cwd={markdownCwd} />
2643− {(() => {
2644− const turnSummary = turnDiffSummaryByAssistantMessageId.get(
2645− row.message.id,
2646− );
2647− if (!turnSummary) return null;
2648− const checkpointFiles = turnSummary.files;
2649− if (checkpointFiles.length === 0) return null;
2650− const summaryStat = checkpointFiles.reduce(
2651− (acc, file) => {
2652− if (
2653− typeof file.additions !== "number" ||
2654− typeof file.deletions !== "number"
2655− ) {
2656− return acc;
2657− }
2658− return {
2659− additions: acc.additions + file.additions,
2660− deletions: acc.deletions + file.deletions,
2661− };
2662− },
2663− { additions: 0, deletions: 0 },
2664− );
2665− const changedFileCountLabel = String(checkpointFiles.length);
2666− return (
2667− <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
2668− <div className="mb-1.5 flex items-center justify-between gap-2">
2669− <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
2670− <span>Changed files ({changedFileCountLabel})</span>
2671− {(summaryStat.additions > 0 || summaryStat.deletions > 0) && (
2672− <>
2673− <span className="mx-1">•</span>
2674− <span className="text-success">+{summaryStat.additions}</span>
2675− <span className="mx-0.5 text-muted-foreground/70">/</span>
2676− <span className="text-destructive">
2677− -{summaryStat.deletions}
2678− </span>
2679− </>
2680− )}
2681− </p>
2682− <Button
2683− type="button"
2684− size="xs"
2685− variant="outline"
2686− onClick={() =>
2687− onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)
2688− }
2689− >
2690− View diff
2691− </Button>
2692− </div>
2693− <div className="flex flex-wrap gap-1.5">
2694− {checkpointFiles.map((file) => (
2695− <button
2696− key={`${turnSummary.turnId}:${file.path}`}
2697− type="button"
2698− className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90"
2699− onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)}
2700− >
2701− {(() => {
2702− const stat =
2703− typeof file.additions === "number" &&
2704− typeof file.deletions === "number"
2705− ? {
2706− additions: file.additions,
2707− deletions: file.deletions,
2708− }
2709− : null;
2710− if (!stat) {
2711− return file.path;
2712− }
2713− return (
2714− <>
2715− <span>{file.path}</span>
2716− <span className="ml-1 text-muted-foreground/70">(</span>
2717− <span className="text-success">+{stat.additions}</span>
2718− <span className="mx-0.5 text-muted-foreground/70">/</span>
2719− <span className="text-destructive">
2720− -{stat.deletions}
2721− </span>
2722− <span className="text-muted-foreground/70">)</span>
2723− </>
2724− );
2725− })()}
2726− </button>
2727− ))}
2728− </div>
2729− </div>
2730− );
2731− })()}
2732− <p className="mt-1.5 text-[10px] text-muted-foreground/30">
2733− {formatMessageMeta(
2734− row.message.createdAt,
2735− row.message.streaming
2736− ? formatElapsed(row.message.createdAt, nowIso)
2737− : formatElapsed(row.message.createdAt, row.message.completedAt),
2738− )}
2739− </p>
2740− </div>
2741− </>
2742− );
2743− })()}
2786+ return (
2787+ <div className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden">
2788+ {virtualizedRowCount > 0 && (
2789+ <div className="relative" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
2790+ {virtualRows.map((virtualRow: VirtualItem) => {
2791+ const row = rows[virtualRow.index];
2792+ if (!row) return null;
2793+
2794+ return (
2795+ <div
2796+ key={`virtual-row:${row.id}`}
2797+ data-index={virtualRow.index}
2798+ ref={rowVirtualizer.measureElement}
2799+ className="absolute left-0 top-0 w-full"
2800+ style={{ transform: `translateY(${virtualRow.start}px)` }}
2801+ >
2802+ {renderRowContent(row)}
2803+ </div>
2804+ );
2805+ })}
2806+ </div>
2807+ )}
27442808
2745− {row.kind === "working" && (
2746− <div className="flex items-center gap-2 py-0.5 pl-1.5">
2747− <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
2748− <div className="flex items-center pt-1">
2749− <span className="inline-flex items-center gap-[3px]">
2750− <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
2751− <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
2752− <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
2753− </span>
2754− </div>
2755− </div>
2756− )}
2757− </div>
2758− </div>
2759− );
2760− })}
2809+ {nonVirtualizedRows.map((row) => (
2810+ <div key={`non-virtual-row:${row.id}`}>{renderRowContent(row)}</div>
2811+ ))}
27612812 </div>
27622813 );
27632814 });