authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-18 23:41:24-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-18 23:54:48-08:00
log5cf140b0ee2d7320d101b082217a90afb7f230ce
tree8ef92fef3e68e5ec9bfc6f9dec956ec4a5830df0
parent85085491da415786b4d16c1942ec74a97c21958e

Virtualize chat timeline rendering for large threads

- Add `@tanstack/react-virtual` to web dependencies - Render chat timeline with virtualized rows for work logs, messages, and working indicator - Replace end-of-list sentinel approach with scroll-container-based virtualization

3 files changed, 384 insertions(+), 282 deletions(-)

apps/web/package.json+1
......@@ -17,6 +17,7 @@
1717 "@tanstack/react-pacer": "^0.19.4",
1818 "@tanstack/react-query": "^5.90.0",
1919 "@tanstack/react-router": "^1.160.2",
20 "@tanstack/react-virtual": "^3.13.18",
2021 "@xterm/addon-fit": "^0.11.0",
2122 "@xterm/xterm": "^6.0.0",
2223 "class-variance-authority": "^0.7.1",
apps/web/src/components/ChatView.tsx+378-282
......@@ -16,7 +16,6 @@ import {
1616 type ClipboardEvent,
1717 type DragEvent,
1818 type FormEvent,
19 Fragment,
2019 type KeyboardEvent,
2120 memo,
2221 type RefObject,
......@@ -29,6 +28,7 @@ import {
2928} from "react";
3029import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
3130import { useDebouncedValue } from "@tanstack/react-pacer";
31import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual";
3232import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
3333import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
3434import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
......@@ -373,7 +373,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
373373 Record<string, string>
374374 >(() => readLastInvokedScriptByProjectFromStorage());
375375 const messagesScrollRef = useRef<HTMLDivElement>(null);
376 const messagesEndRef = useRef<HTMLDivElement>(null);
377376 const shouldAutoScrollRef = useRef(true);
378377 const textareaRef = useRef<HTMLTextAreaElement>(null);
379378 const composerCommandInputRef = useRef<HTMLInputElement>(null);
......@@ -1985,6 +1984,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
19851984 <MessagesTimeline
19861985 hasMessages={activeThread.messages.length > 0}
19871986 isWorking={isWorking}
1987 scrollContainerRef={messagesScrollRef}
19881988 timelineEntries={timelineEntries}
19891989 completionDividerBeforeEntryId={completionDividerBeforeEntryId}
19901990 completionSummary={completionSummary}
......@@ -1998,7 +1998,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
19981998 onRevertUserMessage={onRevertUserMessage}
19991999 isRevertingCheckpoint={isRevertingCheckpoint}
20002000 onImageExpand={onExpandTimelineImage}
2001 messagesEndRef={messagesEndRef}
20022001 />
20032002 </div>
20042003
......@@ -2450,6 +2449,7 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({
24502449interface MessagesTimelineProps {
24512450 hasMessages: boolean;
24522451 isWorking: boolean;
2452 scrollContainerRef: RefObject<HTMLDivElement | null>;
24532453 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
24542454 completionDividerBeforeEntryId: string | null;
24552455 completionSummary: string | null;
......@@ -2463,12 +2463,29 @@ interface MessagesTimelineProps {
24632463 onRevertUserMessage: (messageId: string) => void;
24642464 isRevertingCheckpoint: boolean;
24652465 onImageExpand: (image: ExpandedImagePreview) => void;
2466 messagesEndRef: RefObject<HTMLDivElement | null>;
24672466}
24682467
2468type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
2469type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];
2470type TimelineWorkEntry = Extract<TimelineEntry, { kind: "work" }>["entry"];
2471type TimelineRow =
2472 | {
2473 kind: "work";
2474 id: string;
2475 groupedEntries: TimelineWorkEntry[];
2476 }
2477 | {
2478 kind: "message";
2479 id: string;
2480 message: TimelineMessage;
2481 showCompletionDivider: boolean;
2482 }
2483 | { kind: "working"; id: string };
2484
24692485const MessagesTimeline = memo(function MessagesTimeline({
24702486 hasMessages,
24712487 isWorking,
2488 scrollContainerRef,
24722489 timelineEntries,
24732490 completionDividerBeforeEntryId,
24742491 completionSummary,
......@@ -2482,8 +2499,67 @@ const MessagesTimeline = memo(function MessagesTimeline({
24822499 onRevertUserMessage,
24832500 isRevertingCheckpoint,
24842501 onImageExpand,
2485 messagesEndRef,
24862502}: MessagesTimelineProps) {
2503 const rows = useMemo<TimelineRow[]>(() => {
2504 const nextRows: TimelineRow[] = [];
2505
2506 for (let index = 0; index < timelineEntries.length; index += 1) {
2507 const timelineEntry = timelineEntries[index];
2508 if (!timelineEntry) {
2509 continue;
2510 }
2511
2512 if (timelineEntry.kind === "work") {
2513 const groupedEntries = [timelineEntry.entry];
2514 let cursor = index + 1;
2515 while (cursor < timelineEntries.length) {
2516 const nextEntry = timelineEntries[cursor];
2517 if (!nextEntry || nextEntry.kind !== "work") break;
2518 groupedEntries.push(nextEntry.entry);
2519 cursor += 1;
2520 }
2521 nextRows.push({
2522 kind: "work",
2523 id: timelineEntry.id,
2524 groupedEntries,
2525 });
2526 index = cursor - 1;
2527 continue;
2528 }
2529
2530 nextRows.push({
2531 kind: "message",
2532 id: timelineEntry.id,
2533 message: timelineEntry.message,
2534 showCompletionDivider:
2535 timelineEntry.message.role === "assistant" &&
2536 completionDividerBeforeEntryId === timelineEntry.id,
2537 });
2538 }
2539
2540 if (isWorking) {
2541 nextRows.push({ kind: "working", id: "working-indicator-row" });
2542 }
2543
2544 return nextRows;
2545 }, [timelineEntries, completionDividerBeforeEntryId, isWorking]);
2546
2547 const rowVirtualizer = useVirtualizer({
2548 count: rows.length,
2549 getScrollElement: () => scrollContainerRef.current,
2550 estimateSize: (index: number) => {
2551 const row = rows[index];
2552 if (!row) return 96;
2553 if (row.kind === "work") return 112;
2554 if (row.kind === "working") return 40;
2555 return row.message.role === "assistant" ? 220 : 170;
2556 },
2557 measureElement: (element: HTMLElement) => element.getBoundingClientRect().height,
2558 overscan: 8,
2559 });
2560
2561 const virtualRows = rowVirtualizer.getVirtualItems();
2562
24872563 if (!hasMessages && !isWorking) {
24882564 return (
24892565 <div className="flex h-full items-center justify-center">
......@@ -2495,296 +2571,316 @@ const MessagesTimeline = memo(function MessagesTimeline({
24952571 }
24962572
24972573 return (
2498 <div className="mx-auto max-w-3xl space-y-4">
2499 {timelineEntries.map((timelineEntry, index) => {
2500 if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") {
2501 return null;
2502 }
2503
2504 const showCompletionDivider =
2505 timelineEntry.kind === "message" &&
2506 timelineEntry.message.role === "assistant" &&
2507 completionDividerBeforeEntryId === timelineEntry.id;
2508
2509 if (timelineEntry.kind === "work") {
2510 const groupedEntries = [timelineEntry.entry];
2511 let cursor = index + 1;
2512 while (cursor < timelineEntries.length) {
2513 const nextEntry = timelineEntries[cursor];
2514 if (!nextEntry || nextEntry.kind !== "work") break;
2515 groupedEntries.push(nextEntry.entry);
2516 cursor += 1;
2517 }
2518
2519 const groupId = timelineEntry.id;
2520 const isExpanded = expandedWorkGroups[groupId] ?? false;
2521 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
2522 const visibleEntries =
2523 hasOverflow && !isExpanded
2524 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
2525 : groupedEntries;
2526 const hiddenCount = groupedEntries.length - visibleEntries.length;
2527 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
2528 const groupLabel = onlyToolEntries
2529 ? groupedEntries.length === 1
2530 ? "Tool call"
2531 : `Tool calls (${groupedEntries.length})`
2532 : groupedEntries.length === 1
2533 ? "Work event"
2534 : `Work log (${groupedEntries.length})`;
2535
2536 return (
2537 <Fragment key={timelineEntry.id}>
2538 <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">
2539 <div className="mb-1.5 flex items-center justify-between gap-3">
2540 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
2541 {groupLabel}
2542 </p>
2543 {hasOverflow && (
2544 <button
2545 type="button"
2546 className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"
2547 onClick={() => onToggleWorkGroup(groupId)}
2548 >
2549 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
2550 </button>
2551 )}
2552 </div>
2553 <div className="space-y-1">
2554 {visibleEntries.map((workEntry) => (
2555 <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5">
2556 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
2557 <p
2558 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}
2559 >
2560 {workEntry.detail ? (
2561 <>
2562 {workEntry.label}
2563 <span
2564 className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60"
2565 title={workEntry.detail}
2566 >
2567 {workEntry.detail}
2568 </span>
2569 </>
2570 ) : (
2571 workEntry.label
2572 )}
2573 </p>
2574 </div>
2575 ))}
2576 </div>
2577 </div>
2578 </Fragment>
2579 );
2580 }
2581
2582 if (timelineEntry.message.role === "user") {
2583 const userImages = timelineEntry.message.attachments ?? [];
2584 const canRevertAgentWork = revertTurnCountByUserMessageId.has(timelineEntry.message.id);
2585 return (
2586 <Fragment key={timelineEntry.id}>
2587 <div className="flex justify-end">
2588 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
2589 {userImages.length > 0 && (
2590 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
2591 {userImages.map((image) => (
2592 <div
2593 key={image.id}
2594 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
2595 >
2596 {image.previewUrl ? (
2597 <img
2598 src={image.previewUrl}
2599 alt={image.name}
2600 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
2601 onClick={() =>
2602 onImageExpand({ src: image.previewUrl!, name: image.name })
2603 }
2604 />
2605 ) : (
2606 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">
2607 {image.name}
2608 </div>
2609 )}
2610 </div>
2611 ))}
2612 </div>
2613 )}
2614 {timelineEntry.message.text && (
2615 <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground">
2616 {timelineEntry.message.text}
2617 </pre>
2618 )}
2619 <div className="mt-1.5 flex items-center justify-end gap-2">
2620 {canRevertAgentWork && (
2621 <Button
2622 type="button"
2623 size="xs"
2624 variant="outline"
2625 disabled={isRevertingCheckpoint || isWorking}
2626 onClick={() => onRevertUserMessage(timelineEntry.message.id)}
2627 >
2628 <Undo2Icon className="size-3" />
2629 </Button>
2630 )}
2631 <p className="text-right text-[10px] text-muted-foreground/30">
2632 {formatTimestamp(timelineEntry.message.createdAt)}
2633 </p>
2634 </div>
2635 </div>
2636 </div>
2637 </Fragment>
2638 );
2639 }
2574 <div
2575 className="relative mx-auto max-w-3xl"
2576 style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
2577 >
2578 {virtualRows.map((virtualRow: VirtualItem) => {
2579 const row = rows[virtualRow.index];
2580 if (!row) return null;
26402581
26412582 return (
2642 <Fragment key={timelineEntry.id}>
2643 {showCompletionDivider && (
2644 <div className="my-3 flex items-center gap-3">
2645 <span className="h-px flex-1 bg-border" />
2646 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
2647 {completionSummary ? `Response • ${completionSummary}` : "Response"}
2648 </span>
2649 <span className="h-px flex-1 bg-border" />
2650 </div>
2651 )}
2652 <div className="px-1 py-0.5">
2653 <ChatMarkdown
2654 text={
2655 timelineEntry.message.text ||
2656 (timelineEntry.message.streaming ? "" : "(empty response)")
2657 }
2658 />
2659 {(() => {
2660 const turnSummary = turnDiffSummaryByAssistantMessageId.get(
2661 timelineEntry.message.id,
2662 );
2663 if (!turnSummary) return null;
2664 const isCheckpointDiffLoading =
2665 !turnSummary.checkpointDiffLoaded && turnSummary.files.length === 0;
2666 const summaryStat = turnSummary.unifiedDiff
2667 ? countDiffStat(turnSummary.unifiedDiff)
2668 : turnSummary.files.reduce(
2669 (acc, file) => {
2670 const next =
2671 typeof file.additions === "number" && typeof file.deletions === "number"
2672 ? { additions: file.additions, deletions: file.deletions }
2673 : file.diff
2674 ? countDiffStat(file.diff)
2675 : null;
2676 if (!next) {
2677 return acc;
2678 }
2679 return {
2680 additions: acc.additions + next.additions,
2681 deletions: acc.deletions + next.deletions,
2682 };
2683 },
2684 { additions: 0, deletions: 0 },
2685 );
2686 const changedFileCountLabel = isCheckpointDiffLoading
2687 ? "..."
2688 : String(turnSummary.files.length);
2689 return (
2690 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
2691 <div className="mb-1.5 flex items-center justify-between gap-2">
2692 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
2693 <span>Changed files ({changedFileCountLabel})</span>
2694 {!isCheckpointDiffLoading &&
2695 (summaryStat.additions > 0 || summaryStat.deletions > 0) && (
2696 <>
2697 <span className="mx-1">•</span>
2698 <span className="text-success">+{summaryStat.additions}</span>
2699 <span className="mx-0.5 text-muted-foreground/70">/</span>
2700 <span className="text-destructive">-{summaryStat.deletions}</span>
2701 </>
2702 )}
2703 </p>
2704 <Button
2705 type="button"
2706 size="xs"
2707 variant="outline"
2708 onClick={() =>
2709 onOpenTurnDiff(turnSummary.turnId, turnSummary.files[0]?.path)
2710 }
2711 >
2712 View diff
2713 </Button>
2714 </div>
2715 {isCheckpointDiffLoading && (
2716 <p className="mb-1.5 text-[11px] text-muted-foreground/70">
2717 Loading checkpoint diff...
2718 </p>
2719 )}
2720 {turnSummary.files.length > 0 ? (
2721 <div className="flex flex-wrap gap-1.5">
2722 {turnSummary.files.map((file) => (
2583 <div
2584 key={row.id}
2585 data-index={virtualRow.index}
2586 ref={rowVirtualizer.measureElement}
2587 className="absolute left-0 top-0 w-full"
2588 style={{ transform: `translateY(${virtualRow.start}px)` }}
2589 >
2590 <div className="pb-4">
2591 {row.kind === "work" &&
2592 (() => {
2593 const groupId = row.id;
2594 const groupedEntries = row.groupedEntries;
2595 const isExpanded = expandedWorkGroups[groupId] ?? false;
2596 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
2597 const visibleEntries =
2598 hasOverflow && !isExpanded
2599 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
2600 : groupedEntries;
2601 const hiddenCount = groupedEntries.length - visibleEntries.length;
2602 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
2603 const groupLabel = onlyToolEntries
2604 ? groupedEntries.length === 1
2605 ? "Tool call"
2606 : `Tool calls (${groupedEntries.length})`
2607 : groupedEntries.length === 1
2608 ? "Work event"
2609 : `Work log (${groupedEntries.length})`;
2610
2611 return (
2612 <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">
2613 <div className="mb-1.5 flex items-center justify-between gap-3">
2614 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
2615 {groupLabel}
2616 </p>
2617 {hasOverflow && (
27232618 <button
2724 key={`${turnSummary.turnId}:${file.path}`}
27252619 type="button"
2726 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"
2727 onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)}
2620 className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"
2621 onClick={() => onToggleWorkGroup(groupId)}
2622 >
2623 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
2624 </button>
2625 )}
2626 </div>
2627 <div className="space-y-1">
2628 {visibleEntries.map((workEntry) => (
2629 <div
2630 key={`work-row:${workEntry.id}`}
2631 className="flex items-start gap-2 py-0.5"
27282632 >
2729 {(() => {
2730 const stat =
2731 typeof file.additions === "number" &&
2732 typeof file.deletions === "number"
2733 ? { additions: file.additions, deletions: file.deletions }
2734 : file.diff
2735 ? countDiffStat(file.diff)
2736 : null;
2737 if (!stat) {
2738 return file.path;
2739 }
2740 return (
2633 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
2634 <p
2635 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}
2636 >
2637 {workEntry.detail ? (
27412638 <>
2742 <span>{file.path}</span>
2743 <span className="ml-1 text-muted-foreground/70">(</span>
2744 <span className="text-success">+{stat.additions}</span>
2745 <span className="mx-0.5 text-muted-foreground/70">/</span>
2746 <span className="text-destructive">-{stat.deletions}</span>
2747 <span className="text-muted-foreground/70">)</span>
2639 {workEntry.label}
2640 <span
2641 className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60"
2642 title={workEntry.detail}
2643 >
2644 {workEntry.detail}
2645 </span>
27482646 </>
2749 );
2750 })()}
2751 </button>
2647 ) : (
2648 workEntry.label
2649 )}
2650 </p>
2651 </div>
27522652 ))}
27532653 </div>
2754 ) : !isCheckpointDiffLoading ? (
2755 <p className="text-[11px] text-muted-foreground/70">No changed files.</p>
2756 ) : null}
2654 </div>
2655 );
2656 })()}
2657
2658 {row.kind === "message" &&
2659 row.message.role === "user" &&
2660 (() => {
2661 const userImages = row.message.attachments ?? [];
2662 const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id);
2663 return (
2664 <div className="flex justify-end">
2665 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
2666 {userImages.length > 0 && (
2667 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
2668 {userImages.map(
2669 (image: NonNullable<TimelineMessage["attachments"]>[number]) => (
2670 <div
2671 key={image.id}
2672 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
2673 >
2674 {image.previewUrl ? (
2675 <img
2676 src={image.previewUrl}
2677 alt={image.name}
2678 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
2679 onClick={() =>
2680 onImageExpand({ src: image.previewUrl!, name: image.name })
2681 }
2682 />
2683 ) : (
2684 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">
2685 {image.name}
2686 </div>
2687 )}
2688 </div>
2689 ),
2690 )}
2691 </div>
2692 )}
2693 {row.message.text && (
2694 <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground">
2695 {row.message.text}
2696 </pre>
2697 )}
2698 <div className="mt-1.5 flex items-center justify-end gap-2">
2699 {canRevertAgentWork && (
2700 <Button
2701 type="button"
2702 size="xs"
2703 variant="outline"
2704 disabled={isRevertingCheckpoint || isWorking}
2705 onClick={() => onRevertUserMessage(row.message.id)}
2706 >
2707 <Undo2Icon className="size-3" />
2708 </Button>
2709 )}
2710 <p className="text-right text-[10px] text-muted-foreground/30">
2711 {formatTimestamp(row.message.createdAt)}
2712 </p>
2713 </div>
2714 </div>
2715 </div>
2716 );
2717 })()}
2718
2719 {row.kind === "message" &&
2720 row.message.role === "assistant" &&
2721 (() => {
2722 const messageText =
2723 row.message.text || (row.message.streaming ? "" : "(empty response)");
2724 return (
2725 <>
2726 {row.showCompletionDivider && (
2727 <div className="my-3 flex items-center gap-3">
2728 <span className="h-px flex-1 bg-border" />
2729 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
2730 {completionSummary ? `Response • ${completionSummary}` : "Response"}
2731 </span>
2732 <span className="h-px flex-1 bg-border" />
2733 </div>
2734 )}
2735 <div className="px-1 py-0.5">
2736 <ChatMarkdown text={messageText} />
2737 {(() => {
2738 const turnSummary = turnDiffSummaryByAssistantMessageId.get(
2739 row.message.id,
2740 );
2741 if (!turnSummary) return null;
2742 const isCheckpointDiffLoading =
2743 !turnSummary.checkpointDiffLoaded && turnSummary.files.length === 0;
2744 const summaryStat = turnSummary.unifiedDiff
2745 ? countDiffStat(turnSummary.unifiedDiff)
2746 : turnSummary.files.reduce(
2747 (acc, file) => {
2748 const next =
2749 typeof file.additions === "number" &&
2750 typeof file.deletions === "number"
2751 ? { additions: file.additions, deletions: file.deletions }
2752 : file.diff
2753 ? countDiffStat(file.diff)
2754 : null;
2755 if (!next) {
2756 return acc;
2757 }
2758 return {
2759 additions: acc.additions + next.additions,
2760 deletions: acc.deletions + next.deletions,
2761 };
2762 },
2763 { additions: 0, deletions: 0 },
2764 );
2765 const changedFileCountLabel = isCheckpointDiffLoading
2766 ? "..."
2767 : String(turnSummary.files.length);
2768 return (
2769 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
2770 <div className="mb-1.5 flex items-center justify-between gap-2">
2771 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
2772 <span>Changed files ({changedFileCountLabel})</span>
2773 {!isCheckpointDiffLoading &&
2774 (summaryStat.additions > 0 || summaryStat.deletions > 0) && (
2775 <>
2776 <span className="mx-1">•</span>
2777 <span className="text-success">
2778 +{summaryStat.additions}
2779 </span>
2780 <span className="mx-0.5 text-muted-foreground/70">/</span>
2781 <span className="text-destructive">
2782 -{summaryStat.deletions}
2783 </span>
2784 </>
2785 )}
2786 </p>
2787 <Button
2788 type="button"
2789 size="xs"
2790 variant="outline"
2791 onClick={() =>
2792 onOpenTurnDiff(turnSummary.turnId, turnSummary.files[0]?.path)
2793 }
2794 >
2795 View diff
2796 </Button>
2797 </div>
2798 {isCheckpointDiffLoading && (
2799 <p className="mb-1.5 text-[11px] text-muted-foreground/70">
2800 Loading checkpoint diff...
2801 </p>
2802 )}
2803 {turnSummary.files.length > 0 ? (
2804 <div className="flex flex-wrap gap-1.5">
2805 {turnSummary.files.map((file) => (
2806 <button
2807 key={`${turnSummary.turnId}:${file.path}`}
2808 type="button"
2809 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"
2810 onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)}
2811 >
2812 {(() => {
2813 const stat =
2814 typeof file.additions === "number" &&
2815 typeof file.deletions === "number"
2816 ? {
2817 additions: file.additions,
2818 deletions: file.deletions,
2819 }
2820 : file.diff
2821 ? countDiffStat(file.diff)
2822 : null;
2823 if (!stat) {
2824 return file.path;
2825 }
2826 return (
2827 <>
2828 <span>{file.path}</span>
2829 <span className="ml-1 text-muted-foreground/70">(</span>
2830 <span className="text-success">+{stat.additions}</span>
2831 <span className="mx-0.5 text-muted-foreground/70">
2832 /
2833 </span>
2834 <span className="text-destructive">
2835 -{stat.deletions}
2836 </span>
2837 <span className="text-muted-foreground/70">)</span>
2838 </>
2839 );
2840 })()}
2841 </button>
2842 ))}
2843 </div>
2844 ) : !isCheckpointDiffLoading ? (
2845 <p className="text-[11px] text-muted-foreground/70">
2846 No changed files.
2847 </p>
2848 ) : null}
2849 </div>
2850 );
2851 })()}
2852 <p className="mt-1.5 text-[10px] text-muted-foreground/30">
2853 {formatMessageMeta(
2854 row.message.createdAt,
2855 row.message.streaming
2856 ? formatElapsed(row.message.createdAt, nowIso)
2857 : formatElapsed(
2858 row.message.createdAt,
2859 assistantCompletionByItemId.get(row.message.id),
2860 ),
2861 )}
2862 </p>
2863 </div>
2864 </>
2865 );
2866 })()}
2867
2868 {row.kind === "working" && (
2869 <div className="flex items-center gap-2 py-0.5 pl-1.5">
2870 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
2871 <div className="flex items-center pt-1">
2872 <span className="inline-flex items-center gap-[3px]">
2873 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
2874 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
2875 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
2876 </span>
27572877 </div>
2758 );
2759 })()}
2760 <p className="mt-1.5 text-[10px] text-muted-foreground/30">
2761 {formatMessageMeta(
2762 timelineEntry.message.createdAt,
2763 timelineEntry.message.streaming
2764 ? formatElapsed(timelineEntry.message.createdAt, nowIso)
2765 : formatElapsed(
2766 timelineEntry.message.createdAt,
2767 assistantCompletionByItemId.get(timelineEntry.message.id),
2768 ),
2769 )}
2770 </p>
2878 </div>
2879 )}
27712880 </div>
2772 </Fragment>
2881 </div>
27732882 );
27742883 })}
2775 {isWorking && (
2776 <div className="flex items-center gap-2 py-0.5 pl-1.5">
2777 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
2778 <div className="flex items-center pt-1">
2779 <span className="inline-flex items-center gap-[3px]">
2780 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
2781 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
2782 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
2783 </span>
2784 </div>
2785 </div>
2786 )}
2787 <div ref={messagesEndRef} />
27882884 </div>
27892885 );
27902886});
bun.lock+5
......@@ -57,6 +57,7 @@
5757 "@tanstack/react-pacer": "^0.19.4",
5858 "@tanstack/react-query": "^5.90.0",
5959 "@tanstack/react-router": "^1.160.2",
60 "@tanstack/react-virtual": "^3.13.18",
6061 "@xterm/addon-fit": "^0.11.0",
6162 "@xterm/xterm": "^6.0.0",
6263 "class-variance-authority": "^0.7.1",
......@@ -467,6 +468,8 @@
467468
468469 "@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="],
469470
471 "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.18", "", { "dependencies": { "@tanstack/virtual-core": "3.13.18" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A=="],
472
470473 "@tanstack/router-core": ["@tanstack/router-core@1.160.0", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-vbh6OsE0MG+0c+SKh2uk5yEEZlWsxT96Ub2JaTs7ixOvZp3Wu9PTEIe2BA3cShNZhEsDI0Le4NqgY4XIaHLLvA=="],
471474
472475 "@tanstack/router-generator": ["@tanstack/router-generator@1.160.1", "", { "dependencies": { "@tanstack/router-core": "1.160.0", "@tanstack/router-utils": "1.158.0", "@tanstack/virtual-file-routes": "1.154.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-De6TicInwy3/9rQ++RZAyFOvB2oi5UV5T0iiIlxe3jgiOLFxMA4EKKVlT+alDxKnq6udTLam9xqhvGOVZ6a2hw=="],
......@@ -477,6 +480,8 @@
477480
478481 "@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="],
479482
483 "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.18", "", {}, "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg=="],
484
480485 "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.154.7", "", {}, "sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg=="],
481486
482487 "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],