| ... | ... | @@ -1824,6 +1824,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 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 |
| 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 = |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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 | }); |