authorgravatar for cursoragent@cursor.comCursor Agent <cursoragent@cursor.com> 2026-02-28 02:21:25+00:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-27 19:27:05-08:00
log1a33f25970445cfd2367eefaffe41f52abcabee0
tree9a1f6725db1eb049cc321ae2fc217f36bfe43bfa
parent21c823ecdc63ebfae0226f174f639191a971cd85

web: keep active turn out of virtualization

Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>

1 files changed, 336 insertions(+), 285 deletions(-)

apps/web/src/components/ChatView.tsx+336-285
...@@ -1824,6 +1824,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1824,6 +1824,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
1824 <MessagesTimeline1824 <MessagesTimeline
1825 hasMessages={timelineMessages.length > 0}1825 hasMessages={timelineMessages.length > 0}
1826 isWorking={isWorking}1826 isWorking={isWorking}
1827 activeTurnInProgress={!latestTurnSettled}
1828 activeTurnStartedAt={activeLatestTurn?.startedAt ?? null}
1827 scrollContainerRef={messagesScrollRef}1829 scrollContainerRef={messagesScrollRef}
1828 timelineEntries={timelineEntries}1830 timelineEntries={timelineEntries}
1829 completionDividerBeforeEntryId={completionDividerBeforeEntryId}1831 completionDividerBeforeEntryId={completionDividerBeforeEntryId}
...@@ -2351,6 +2353,8 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri...@@ -2351,6 +2353,8 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri
2351interface MessagesTimelineProps {2353interface MessagesTimelineProps {
2352 hasMessages: boolean;2354 hasMessages: boolean;
2353 isWorking: boolean;2355 isWorking: boolean;
2356 activeTurnInProgress: boolean;
2357 activeTurnStartedAt: string | null;
2354 scrollContainerRef: React.RefObject<HTMLDivElement | null>;2358 scrollContainerRef: React.RefObject<HTMLDivElement | null>;
2355 timelineEntries: ReturnType<typeof deriveTimelineEntries>;2359 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
2356 completionDividerBeforeEntryId: string | null;2360 completionDividerBeforeEntryId: string | null;
...@@ -2374,19 +2378,23 @@ type TimelineRow =...@@ -2374,19 +2378,23 @@ type TimelineRow =
2374 | {2378 | {
2375 kind: "work";2379 kind: "work";
2376 id: string;2380 id: string;
2381 createdAt: string;
2377 groupedEntries: TimelineWorkEntry[];2382 groupedEntries: TimelineWorkEntry[];
2378 }2383 }
2379 | {2384 | {
2380 kind: "message";2385 kind: "message";
2381 id: string;2386 id: string;
2387 createdAt: string;
2382 message: TimelineMessage;2388 message: TimelineMessage;
2383 showCompletionDivider: boolean;2389 showCompletionDivider: boolean;
2384 }2390 }
2385 | { kind: "working"; id: string };2391 | { kind: "working"; id: string; createdAt: string | null };
23862392
2387const MessagesTimeline = memo(function MessagesTimeline({2393const MessagesTimeline = memo(function MessagesTimeline({
2388 hasMessages,2394 hasMessages,
2389 isWorking,2395 isWorking,
2396 activeTurnInProgress,
2397 activeTurnStartedAt,
2390 scrollContainerRef,2398 scrollContainerRef,
2391 timelineEntries,2399 timelineEntries,
2392 completionDividerBeforeEntryId,2400 completionDividerBeforeEntryId,
...@@ -2423,6 +2431,7 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2423,6 +2431,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
2423 nextRows.push({2431 nextRows.push({
2424 kind: "work",2432 kind: "work",
2425 id: timelineEntry.id,2433 id: timelineEntry.id,
2434 createdAt: timelineEntry.createdAt,
2426 groupedEntries,2435 groupedEntries,
2427 });2436 });
2428 index = cursor - 1;2437 index = cursor - 1;
...@@ -2432,6 +2441,7 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2432,6 +2441,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
2432 nextRows.push({2441 nextRows.push({
2433 kind: "message",2442 kind: "message",
2434 id: timelineEntry.id,2443 id: timelineEntry.id,
2444 createdAt: timelineEntry.createdAt,
2435 message: timelineEntry.message,2445 message: timelineEntry.message,
2436 showCompletionDivider:2446 showCompletionDivider:
2437 timelineEntry.message.role === "assistant" &&2447 timelineEntry.message.role === "assistant" &&
...@@ -2440,14 +2450,59 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2440,14 +2450,59 @@ const MessagesTimeline = memo(function MessagesTimeline({
2440 }2450 }
24412451
2442 if (isWorking) {2452 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 });
2444 }2458 }
24452459
2446 return nextRows;2460 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
2449 const rowVirtualizer = useVirtualizer({2504 const rowVirtualizer = useVirtualizer({
2450 count: rows.length,2505 count: virtualizedRowCount,
2451 getScrollElement: () => scrollContainerRef.current,2506 getScrollElement: () => scrollContainerRef.current,
2452 estimateSize: (index: number) => {2507 estimateSize: (index: number) => {
2453 const row = rows[index];2508 const row = rows[index];
...@@ -2461,303 +2516,299 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2461,303 +2516,299 @@ const MessagesTimeline = memo(function MessagesTimeline({
2461 });2516 });
24622517
2463 const virtualRows = rowVirtualizer.getVirtualItems();2518 const virtualRows = rowVirtualizer.getVirtualItems();
24642519 const nonVirtualizedRows = rows.slice(virtualizedRowCount);
2465 if (!hasMessages && !isWorking) {2520
2466 return (2521 const renderRowContent = (row: TimelineRow) => (
2467 <div className="flex h-full items-center justify-center">2522 <div className="pb-4">
2468 <p className="text-sm text-muted-foreground/30">2523 {row.kind === "work" &&
2469 Send a message to start the conversation.2524 (() => {
2470 </p>2525 const groupId = row.id;
2471 </div>2526 const groupedEntries = row.groupedEntries;
2472 );2527 const isExpanded = expandedWorkGroups[groupId] ?? false;
2473 }2528 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
24742529 const visibleEntries =
2475 return (2530 hasOverflow && !isExpanded
2476 <div2531 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
2477 className="relative mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden"2532 : groupedEntries;
2478 style={{ height: `${rowVirtualizer.getTotalSize()}px` }}2533 const hiddenCount = groupedEntries.length - visibleEntries.length;
2479 >2534 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
2480 {virtualRows.map((virtualRow: VirtualItem) => {2535 const groupLabel = onlyToolEntries
2481 const row = rows[virtualRow.index];2536 ? groupedEntries.length === 1
2482 if (!row) return null;2537 ? "Tool call"
24832538 : `Tool calls (${groupedEntries.length})`
2484 return (2539 : groupedEntries.length === 1
2485 <div2540 ? "Work event"
2486 key={row.id}2541 : `Work log (${groupedEntries.length})`;
2487 data-index={virtualRow.index}2542
2488 ref={rowVirtualizer.measureElement}2543 return (
2489 className="absolute left-0 top-0 w-full"2544 <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">
2490 style={{ transform: `translateY(${virtualRow.start}px)` }}2545 <div className="mb-1.5 flex items-center justify-between gap-3">
2491 >2546 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
2492 <div className="pb-4">2547 {groupLabel}
2493 {row.kind === "work" &&2548 </p>
2494 (() => {2549 {hasOverflow && (
2495 const groupId = row.id;2550 <button
2496 const groupedEntries = row.groupedEntries;2551 type="button"
2497 const isExpanded = expandedWorkGroups[groupId] ?? false;2552 className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"
2498 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;2553 onClick={() => onToggleWorkGroup(groupId)}
2499 const visibleEntries =2554 >
2500 hasOverflow && !isExpanded2555 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
2501 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)2556 </button>
2502 : groupedEntries;2557 )}
2503 const hiddenCount = groupedEntries.length - visibleEntries.length;2558 </div>
2504 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");2559 <div className="space-y-1">
2505 const groupLabel = onlyToolEntries2560 {visibleEntries.map((workEntry) => (
2506 ? groupedEntries.length === 12561 <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5">
2507 ? "Tool call"2562 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
2508 : `Tool calls (${groupedEntries.length})`2563 <p
2509 : groupedEntries.length === 12564 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}
2510 ? "Work event"2565 >
2511 : `Work log (${groupedEntries.length})`;2566 {workEntry.detail ? (
25122567 <>
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);
2513 return (2686 return (
2514 <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">2687 <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">2688 <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">2689 <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 )}
2518 </p>2699 </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) => (
2520 <button2713 <button
2714 key={`${turnSummary.turnId}:${file.path}`}
2521 type="button"2715 type="button"
2522 className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"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"
2523 onClick={() => onToggleWorkGroup(groupId)}2717 onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)}
2524 >2718 >
2525 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}2719 {(() => {
2526 </button>2720 const stat =
2527 )}2721 typeof file.additions === "number" &&
2528 </div>2722 typeof file.deletions === "number"
2529 <div className="space-y-1">2723 ? {
2530 {visibleEntries.map((workEntry) => (2724 additions: file.additions,
2531 <div2725 deletions: file.deletions,
2532 key={`work-row:${workEntry.id}`}2726 }
2533 className="flex items-start gap-2 py-0.5"2727 : null;
2534 >2728 if (!stat) {
2535 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />2729 return file.path;
2536 <p2730 }
2537 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}2731 return (
2538 >
2539 {workEntry.detail ? (
2540 <>2732 <>
2541 {workEntry.label}2733 <span>{file.path}</span>
2542 <span2734 <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"2735 <span className="text-success">+{stat.additions}</span>
2544 title={workEntry.detail}2736 <span className="mx-0.5 text-muted-foreground/70">/</span>
2545 >2737 <span className="text-destructive">-{stat.deletions}</span>
2546 {workEntry.detail}2738 <span className="text-muted-foreground/70">)</span>
2547 </span>
2548 </>2739 </>
2549 ) : (2740 );
2550 workEntry.label2741 })()}
2551 )}2742 </button>
2552 </p>
2553 </div>
2554 ))}2743 ))}
2555 </div>2744 </div>
2556 </div>2745 </div>
2557 );2746 );
2558 })()}2747 })()}
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" &&2776 if (!hasMessages && !isWorking) {
2561 row.message.role === "user" &&2777 return (
2562 (() => {2778 <div className="flex h-full items-center justify-center">
2563 const userImages = row.message.attachments ?? [];2779 <p className="text-sm text-muted-foreground/30">
2564 const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id);2780 Send a message to start the conversation.
2565 return (2781 </p>
2566 <div className="flex justify-end">2782 </div>
2567 <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">2783 );
2568 {userImages.length > 0 && (2784 }
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 })()}
26242785
2625 {row.kind === "message" &&2786 return (
2626 row.message.role === "assistant" &&2787 <div className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden">
2627 (() => {2788 {virtualizedRowCount > 0 && (
2628 const messageText =2789 <div className="relative" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
2629 row.message.text || (row.message.streaming ? "" : "(empty response)");2790 {virtualRows.map((virtualRow: VirtualItem) => {
2630 return (2791 const row = rows[virtualRow.index];
2631 <>2792 if (!row) return null;
2632 {row.showCompletionDivider && (2793
2633 <div className="my-3 flex items-center gap-3">2794 return (
2634 <span className="h-px flex-1 bg-border" />2795 <div
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">2796 key={`virtual-row:${row.id}`}
2636 {completionSummary ? `Response • ${completionSummary}` : "Response"}2797 data-index={virtualRow.index}
2637 </span>2798 ref={rowVirtualizer.measureElement}
2638 <span className="h-px flex-1 bg-border" />2799 className="absolute left-0 top-0 w-full"
2639 </div>2800 style={{ transform: `translateY(${virtualRow.start}px)` }}
2640 )}2801 >
2641 <div className="min-w-0 px-1 py-0.5">2802 {renderRowContent(row)}
2642 <ChatMarkdown text={messageText} cwd={markdownCwd} />2803 </div>
2643 {(() => {2804 );
2644 const turnSummary = turnDiffSummaryByAssistantMessageId.get(2805 })}
2645 row.message.id,2806 </div>
2646 );2807 )}
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 })()}
27442808
2745 {row.kind === "working" && (2809 {nonVirtualizedRows.map((row) => (
2746 <div className="flex items-center gap-2 py-0.5 pl-1.5">2810 <div key={`non-virtual-row:${row.id}`}>{renderRowContent(row)}</div>
2747 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />2811 ))}
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 })}
2761 </div>2812 </div>
2762 );2813 );
2763});2814});