History

Disable virtualization for the current in-progress turn

When isWorking, rows belonging to the active turn (from the last user message onward) are rendered in normal document flow instead of through the virtualizer. This prevents height estimation mismatches during streaming that cause layout collisions.

Cursor Agent committed 8 months ago

Commit 87cfb961 parent 21c823e1 file · +306 −272Browse files

Unified · Split

apps/web/src/components/ChatView.tsx+306−272
@@ -2446,11 +2446,31 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 2445 unmodified lines
2446 return nextRows;2446 return nextRows;
2447 }, [timelineEntries, completionDividerBeforeEntryId, isWorking]);2447 }, [timelineEntries, completionDividerBeforeEntryId, isWorking]);
2448 2448
2449 // Split rows: completed turns are virtualized, current in-progress turn is not.
2450 // The current turn starts at the last user message when actively working.
2451 const currentTurnStartIndex = useMemo(() => {
2452 if (!isWorking) return rows.length;
2453 for (let i = rows.length - 1; i >= 0; i--) {
2454 const row = rows[i];
2455 if (row?.kind === "message" && row.message.role === "user") return i;
2456 }
2457 return rows.length;
2458 }, [rows, isWorking]);
2459
2460 const virtualizedRows = useMemo(
2461 () => rows.slice(0, currentTurnStartIndex),
2462 [rows, currentTurnStartIndex],
2463 );
2464 const currentTurnRows = useMemo(
2465 () => rows.slice(currentTurnStartIndex),
2466 [rows, currentTurnStartIndex],
2467 );
2468
2449 const rowVirtualizer = useVirtualizer({2469 const rowVirtualizer = useVirtualizer({
2450 count: rows.length,2470 count: virtualizedRows.length,
2451 getScrollElement: () => scrollContainerRef.current,2471 getScrollElement: () => scrollContainerRef.current,
2452 estimateSize: (index: number) => {2472 estimateSize: (index: number) => {
2453 const row = rows[index];2473 const row = virtualizedRows[index];
2454 if (!row) return 96;2474 if (!row) return 96;
2455 if (row.kind === "work") return 112;2475 if (row.kind === "work") return 112;
2456 if (row.kind === "working") return 40;2476 if (row.kind === "working") return 40;
@@ -2472,292 +2492,306 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 15 unmodified lines
2472 );2492 );
2473 }2493 }
2474 2494
2475 return (2495 const renderRowContent = (row: TimelineRow) => (
2476 <div2496 <div className="pb-4">
2477 className="relative mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden"2497 {row.kind === "work" &&
2478 style={{ height: `${rowVirtualizer.getTotalSize()}px` }}2498 (() => {
2479 >2499 const groupId = row.id;
2480 {virtualRows.map((virtualRow: VirtualItem) => {2500 const groupedEntries = row.groupedEntries;
2481 const row = rows[virtualRow.index];2501 const isExpanded = expandedWorkGroups[groupId] ?? false;
2482 if (!row) return null;2502 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
2483 2503 const visibleEntries =
2484 return (2504 hasOverflow && !isExpanded
2485 <div2505 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
2486 key={row.id}2506 : groupedEntries;
2487 data-index={virtualRow.index}2507 const hiddenCount = groupedEntries.length - visibleEntries.length;
2488 ref={rowVirtualizer.measureElement}2508 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
2489 className="absolute left-0 top-0 w-full"2509 const groupLabel = onlyToolEntries
2490 style={{ transform: `translateY(${virtualRow.start}px)` }}2510 ? groupedEntries.length === 1
2491 >2511 ? "Tool call"
2492 <div className="pb-4">2512 : `Tool calls (${groupedEntries.length})`
2493 {row.kind === "work" &&2513 : groupedEntries.length === 1
2494 (() => {2514 ? "Work event"
2495 const groupId = row.id;2515 : `Work log (${groupedEntries.length})`;
2496 const groupedEntries = row.groupedEntries;2516
2497 const isExpanded = expandedWorkGroups[groupId] ?? false;2517 return (
2498 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;2518 <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">
2499 const visibleEntries =2519 <div className="mb-1.5 flex items-center justify-between gap-3">
2500 hasOverflow && !isExpanded2520 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
2501 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)2521 {groupLabel}
2502 : groupedEntries;2522 </p>
2503 const hiddenCount = groupedEntries.length - visibleEntries.length;2523 {hasOverflow && (
2504 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");2524 <button
2505 const groupLabel = onlyToolEntries2525 type="button"
2506 ? groupedEntries.length === 12526 className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"
2507 ? "Tool call"2527 onClick={() => onToggleWorkGroup(groupId)}
2508 : `Tool calls (${groupedEntries.length})`2528 >
2509 : groupedEntries.length === 12529 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
2510 ? "Work event"2530 </button>
2511 : `Work log (${groupedEntries.length})`;2531 )}
2512 2532 </div>
2533 <div className="space-y-1">
2534 {visibleEntries.map((workEntry) => (
2535 <div
2536 key={`work-row:${workEntry.id}`}
2537 className="flex items-start gap-2 py-0.5"
2538 >
2539 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
2540 <p
2541 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}
2542 >
2543 {workEntry.detail ? (
2544 <>
2545 {workEntry.label}
2546 <span
2547 className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60"
2548 title={workEntry.detail}
2549 >
2550 {workEntry.detail}
2551 </span>
2552 </>
2553 ) : (
2554 workEntry.label
2555 )}
2556 </p>
2557 </div>
2558 ))}
2559 </div>
2560 </div>
2561 );
2562 })()}
2563
2564 {row.kind === "message" &&
2565 row.message.role === "user" &&
2566 (() => {
2567 const userImages = row.message.attachments ?? [];
2568 const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id);
2569 return (
2570 <div className="flex justify-end">
2571 <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
2572 {userImages.length > 0 && (
2573 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
2574 {userImages.map(
2575 (image: NonNullable<TimelineMessage["attachments"]>[number]) => (
2576 <div
2577 key={image.id}
2578 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
2579 >
2580 {image.previewUrl ? (
2581 <img
2582 src={image.previewUrl}
2583 alt={image.name}
2584 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
2585 onClick={() =>
2586 onImageExpand({ src: image.previewUrl!, name: image.name })
2587 }
2588 />
2589 ) : (
2590 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">
2591 {image.name}
2592 </div>
2593 )}
2594 </div>
2595 ),
2596 )}
2597 </div>
2598 )}
2599 {row.message.text && (
2600 <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground">
2601 {row.message.text}
2602 </pre>
2603 )}
2604 <div className="mt-1.5 flex items-center justify-end gap-2">
2605 <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
2606 {row.message.text && <MessageCopyButton text={row.message.text} />}
2607 {canRevertAgentWork && (
2608 <Button
2609 type="button"
2610 size="xs"
2611 variant="outline"
2612 disabled={isRevertingCheckpoint || isWorking}
2613 onClick={() => onRevertUserMessage(row.message.id)}
2614 title="Revert to this message"
2615 >
2616 <Undo2Icon className="size-3" />
2617 </Button>
2618 )}
2619 </div>
2620 <p className="text-right text-[10px] text-muted-foreground/30">
2621 {formatTimestamp(row.message.createdAt)}
2622 </p>
2623 </div>
2624 </div>
2625 </div>
2626 );
2627 })()}
2628
2629 {row.kind === "message" &&
2630 row.message.role === "assistant" &&
2631 (() => {
2632 const messageText =
2633 row.message.text || (row.message.streaming ? "" : "(empty response)");
2634 return (
2635 <>
2636 {row.showCompletionDivider && (
2637 <div className="my-3 flex items-center gap-3">
2638 <span className="h-px flex-1 bg-border" />
2639 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
2640 {completionSummary ? `Response • ${completionSummary}` : "Response"}
2641 </span>
2642 <span className="h-px flex-1 bg-border" />
2643 </div>
2644 )}
2645 <div className="min-w-0 px-1 py-0.5">
2646 <ChatMarkdown text={messageText} cwd={markdownCwd} />
2647 {(() => {
2648 const turnSummary = turnDiffSummaryByAssistantMessageId.get(
2649 row.message.id,
2650 );
2651 if (!turnSummary) return null;
2652 const checkpointFiles = turnSummary.files;
2653 if (checkpointFiles.length === 0) return null;
2654 const summaryStat = checkpointFiles.reduce(
2655 (acc, file) => {
2656 if (
2657 typeof file.additions !== "number" ||
2658 typeof file.deletions !== "number"
2659 ) {
2660 return acc;
2661 }
2662 return {
2663 additions: acc.additions + file.additions,
2664 deletions: acc.deletions + file.deletions,
2665 };
2666 },
2667 { additions: 0, deletions: 0 },
2668 );
2669 const changedFileCountLabel = String(checkpointFiles.length);
2513 return (2670 return (
2514 <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">2671 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
2515 <div className="mb-1.5 flex items-center justify-between gap-3">2672 <div className="mb-1.5 flex items-center justify-between gap-2">
2516 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">2673 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
2517 {groupLabel}2674 <span>Changed files ({changedFileCountLabel})</span>
2675 {(summaryStat.additions > 0 || summaryStat.deletions > 0) && (
2676 <>
2677 <span className="mx-1">•</span>
2678 <span className="text-success">+{summaryStat.additions}</span>
2679 <span className="mx-0.5 text-muted-foreground/70">/</span>
2680 <span className="text-destructive">
2681 -{summaryStat.deletions}
2682 </span>
2683 </>
2684 )}
2518 </p>2685 </p>
2519 {hasOverflow && (2686 <Button
2687 type="button"
2688 size="xs"
2689 variant="outline"
2690 onClick={() =>
2691 onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)
2692 }
2693 >
2694 View diff
2695 </Button>
2696 </div>
2697 <div className="flex flex-wrap gap-1.5">
2698 {checkpointFiles.map((file) => (
2520 <button2699 <button
2700 key={`${turnSummary.turnId}:${file.path}`}
2521 type="button"2701 type="button"
2522 className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"2702 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"
2523 onClick={() => onToggleWorkGroup(groupId)}2703 onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)}
2524 >
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 >2704 >
2535 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />2705 {(() => {
2536 <p2706 const stat =
2537 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}2707 typeof file.additions === "number" &&
2538 >2708 typeof file.deletions === "number"
2539 {workEntry.detail ? (2709 ? {
2710 additions: file.additions,
2711 deletions: file.deletions,
2712 }
2713 : null;
2714 if (!stat) {
2715 return file.path;
2716 }
2717 return (
2540 <>2718 <>
2541 {workEntry.label}2719 <span>{file.path}</span>
2542 <span2720 <span className="ml-1 text-muted-foreground/70">(</span>
2543 className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60"2721 <span className="text-success">+{stat.additions}</span>
2544 title={workEntry.detail}2722 <span className="mx-0.5 text-muted-foreground/70">/</span>
2545 >2723 <span className="text-destructive">
2546 {workEntry.detail}2724 -{stat.deletions}
2547 </span>2725 </span>
2726 <span className="text-muted-foreground/70">)</span>
2548 </>2727 </>
2549 ) : (2728 );
2550 workEntry.label2729 })()}
2551 )}2730 </button>
2552 </p>
2553 </div>
2554 ))}2731 ))}
2555 </div>2732 </div>
2556 </div>2733 </div>
2557 );2734 );
2558 })()}2735 })()}
2736 <p className="mt-1.5 text-[10px] text-muted-foreground/30">
2737 {formatMessageMeta(
2738 row.message.createdAt,
2739 row.message.streaming
2740 ? formatElapsed(row.message.createdAt, nowIso)
2741 : formatElapsed(row.message.createdAt, row.message.completedAt),
2742 )}
2743 </p>
2744 </div>
2745 </>
2746 );
2747 })()}
2748
2749 {row.kind === "working" && (
2750 <div className="flex items-center gap-2 py-0.5 pl-1.5">
2751 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
2752 <div className="flex items-center pt-1">
2753 <span className="inline-flex items-center gap-[3px]">
2754 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
2755 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
2756 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
2757 </span>
2758 </div>
2759 </div>
2760 )}
2761 </div>
2762 );
2559 2763
2560 {row.kind === "message" &&2764 return (
2561 row.message.role === "user" &&2765 <div className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden">
2562 (() => {2766 {/* Virtualized completed turns */}
2563 const userImages = row.message.attachments ?? [];2767 {virtualizedRows.length > 0 && (
2564 const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id);2768 <div
2565 return (2769 className="relative"
2566 <div className="flex justify-end">2770 style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
2567 <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">2771 >
2568 {userImages.length > 0 && (2772 {virtualRows.map((virtualRow: VirtualItem) => {
2569 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">2773 const row = virtualizedRows[virtualRow.index];
2570 {userImages.map(2774 if (!row) return null;
2571 (image: NonNullable<TimelineMessage["attachments"]>[number]) => (2775
2572 <div2776 return (
2573 key={image.id}2777 <div
2574 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"2778 key={row.id}
2575 >2779 data-index={virtualRow.index}
2576 {image.previewUrl ? (2780 ref={rowVirtualizer.measureElement}
2577 <img2781 className="absolute left-0 top-0 w-full"
2578 src={image.previewUrl}2782 style={{ transform: `translateY(${virtualRow.start}px)` }}
2579 alt={image.name}2783 >
2580 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"2784 {renderRowContent(row)}
2581 onClick={() =>2785 </div>
2582 onImageExpand({ src: image.previewUrl!, name: image.name })2786 );
2583 }2787 })}
2584 />2788 </div>
2585 ) : (2789 )}
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 })()}
2624
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 })()}
2744 2790
2745 {row.kind === "working" && (2791 {/* Current in-progress turn rendered in normal flow (no virtualization) */}
2746 <div className="flex items-center gap-2 py-0.5 pl-1.5">2792 {currentTurnRows.map((row) => (
2747 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />2793 <div key={row.id}>{renderRowContent(row)}</div>
2748 <div className="flex items-center pt-1">2794 ))}
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 })}
2761 </div>2795 </div>
2762 );2796 );
2763});2797});