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