| ... | @@ -1824,6 +1824,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1824,6 +1824,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1824 | <MessagesTimeline | 1824 | <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 |
| 2351 | interface MessagesTimelineProps { | 2353 | interface 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 }; |
| 2386 | | 2392 | |
| 2387 | const MessagesTimeline = memo(function MessagesTimeline({ | 2393 | const 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 | } |
| 2441 | | 2451 | |
| 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 | } |
| 2445 | | 2459 | |
| 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)); |
| 2448 | | 2503 | |
| 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 | }); |
| 2462 | | 2517 | |
| 2463 | const virtualRows = rowVirtualizer.getVirtualItems(); | 2518 | const virtualRows = rowVirtualizer.getVirtualItems(); |
| 2464 | | 2519 | 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; |
| 2474 | | 2529 | const visibleEntries = |
| 2475 | return ( | 2530 | hasOverflow && !isExpanded |
| 2476 | <div | 2531 | ? 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" |
| 2483 | | 2538 | : `Tool calls (${groupedEntries.length})` |
| 2484 | return ( | 2539 | : groupedEntries.length === 1 |
| 2485 | <div | 2540 | ? "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 && !isExpanded | 2555 | {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 = onlyToolEntries | 2560 | {visibleEntries.map((workEntry) => ( |
| 2506 | ? groupedEntries.length === 1 | 2561 | <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 === 1 | 2564 | className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`} |
| 2510 | ? "Work event" | 2565 | > |
| 2511 | : `Work log (${groupedEntries.length})`; | 2566 | {workEntry.detail ? ( |
| 2512 | | 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 | 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 | <button | 2713 | <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 | <div | 2725 | 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 | <p | 2730 | } |
| 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 | <span | 2734 | <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.label | 2741 | })()} |
| 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 | ); |
| 2559 | | 2775 | |
| 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 | })()} | | |
| 2624 | | 2785 | |
| 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 | })()} | | |
| 2744 | | 2808 | |
| 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 | }); |