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.
Unified · Split
| @@ -2446,11 +2446,31 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 2445 unmodified lines | ||
| 2446 | 2446 | return nextRows; |
| 2447 | 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 | 2469 | const rowVirtualizer = useVirtualizer({ |
| 2450 | − count: rows.length, | |
| 2470 | + count: virtualizedRows.length, | |
| 2451 | 2471 | getScrollElement: () => scrollContainerRef.current, |
| 2452 | 2472 | estimateSize: (index: number) => { |
| 2453 | − const row = rows[index]; | |
| 2473 | + const row = virtualizedRows[index]; | |
| 2454 | 2474 | if (!row) return 96; |
| 2455 | 2475 | if (row.kind === "work") return 112; |
| 2456 | 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 ( | |
| 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 | − | |
| 2495 | + const renderRowContent = (row: TimelineRow) => ( | |
| 2496 | + <div className="pb-4"> | |
| 2497 | + {row.kind === "work" && | |
| 2498 | + (() => { | |
| 2499 | + const groupId = row.id; | |
| 2500 | + const groupedEntries = row.groupedEntries; | |
| 2501 | + const isExpanded = expandedWorkGroups[groupId] ?? false; | |
| 2502 | + const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; | |
| 2503 | + const visibleEntries = | |
| 2504 | + hasOverflow && !isExpanded | |
| 2505 | + ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) | |
| 2506 | + : groupedEntries; | |
| 2507 | + const hiddenCount = groupedEntries.length - visibleEntries.length; | |
| 2508 | + const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); | |
| 2509 | + const groupLabel = onlyToolEntries | |
| 2510 | + ? groupedEntries.length === 1 | |
| 2511 | + ? "Tool call" | |
| 2512 | + : `Tool calls (${groupedEntries.length})` | |
| 2513 | + : groupedEntries.length === 1 | |
| 2514 | + ? "Work event" | |
| 2515 | + : `Work log (${groupedEntries.length})`; | |
| 2516 | + | |
| 2517 | + return ( | |
| 2518 | + <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2"> | |
| 2519 | + <div className="mb-1.5 flex items-center justify-between gap-3"> | |
| 2520 | + <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | |
| 2521 | + {groupLabel} | |
| 2522 | + </p> | |
| 2523 | + {hasOverflow && ( | |
| 2524 | + <button | |
| 2525 | + type="button" | |
| 2526 | + className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80" | |
| 2527 | + onClick={() => onToggleWorkGroup(groupId)} | |
| 2528 | + > | |
| 2529 | + {isExpanded ? "Show less" : `Show ${hiddenCount} more`} | |
| 2530 | + </button> | |
| 2531 | + )} | |
| 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 | 2670 | 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"> | |
| 2671 | + <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> | |
| 2672 | + <div className="mb-1.5 flex items-center justify-between gap-2"> | |
| 2516 | 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 | 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 | 2699 | <button |
| 2700 | + key={`${turnSummary.turnId}:${file.path}`} | |
| 2521 | 2701 | 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)} | |
| 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" | |
| 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" | |
| 2703 | + onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)} | |
| 2534 | 2704 | > |
| 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 ? ( | |
| 2705 | + {(() => { | |
| 2706 | + const stat = | |
| 2707 | + typeof file.additions === "number" && | |
| 2708 | + typeof file.deletions === "number" | |
| 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} | |
| 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} | |
| 2719 | + <span>{file.path}</span> | |
| 2720 | + <span className="ml-1 text-muted-foreground/70">(</span> | |
| 2721 | + <span className="text-success">+{stat.additions}</span> | |
| 2722 | + <span className="mx-0.5 text-muted-foreground/70">/</span> | |
| 2723 | + <span className="text-destructive"> | |
| 2724 | + -{stat.deletions} | |
| 2547 | 2725 | </span> |
| 2726 | + <span className="text-muted-foreground/70">)</span> | |
| 2548 | 2727 | </> |
| 2549 | − ) : ( | |
| 2550 | − workEntry.label | |
| 2551 | − )} | |
| 2552 | − </p> | |
| 2553 | − </div> | |
| 2728 | + ); | |
| 2729 | + })()} | |
| 2730 | + </button> | |
| 2554 | 2731 | ))} |
| 2555 | 2732 | </div> |
| 2556 | 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" && | |
| 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 | − })()} | |
| 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 | − })()} | |
| 2764 | + return ( | |
| 2765 | + <div className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden"> | |
| 2766 | + {/* Virtualized completed turns */} | |
| 2767 | + {virtualizedRows.length > 0 && ( | |
| 2768 | + <div | |
| 2769 | + className="relative" | |
| 2770 | + style={{ height: `${rowVirtualizer.getTotalSize()}px` }} | |
| 2771 | + > | |
| 2772 | + {virtualRows.map((virtualRow: VirtualItem) => { | |
| 2773 | + const row = virtualizedRows[virtualRow.index]; | |
| 2774 | + if (!row) return null; | |
| 2775 | + | |
| 2776 | + return ( | |
| 2777 | + <div | |
| 2778 | + key={row.id} | |
| 2779 | + data-index={virtualRow.index} | |
| 2780 | + ref={rowVirtualizer.measureElement} | |
| 2781 | + className="absolute left-0 top-0 w-full" | |
| 2782 | + style={{ transform: `translateY(${virtualRow.start}px)` }} | |
| 2783 | + > | |
| 2784 | + {renderRowContent(row)} | |
| 2785 | + </div> | |
| 2786 | + ); | |
| 2787 | + })} | |
| 2788 | + </div> | |
| 2789 | + )} | |
| 2744 | 2790 | |
| 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 | − })} | |
| 2791 | + {/* Current in-progress turn rendered in normal flow (no virtualization) */} | |
| 2792 | + {currentTurnRows.map((row) => ( | |
| 2793 | + <div key={row.id}>{renderRowContent(row)}</div> | |
| 2794 | + ))} | |
| 2761 | 2795 | </div> |
| 2762 | 2796 | ); |
| 2763 | 2797 | }); |