| ... | ... | @@ -16,7 +16,6 @@ import { |
| 16 | 16 | type ClipboardEvent, |
| 17 | 17 | type DragEvent, |
| 18 | 18 | type FormEvent, |
| 19 | | Fragment, |
| 20 | 19 | type KeyboardEvent, |
| 21 | 20 | memo, |
| 22 | 21 | type RefObject, |
| ... | ... | @@ -29,6 +28,7 @@ import { |
| 29 | 28 | } from "react"; |
| 30 | 29 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 31 | 30 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 31 | import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; |
| 32 | 32 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; |
| 33 | 33 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 34 | 34 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| ... | ... | @@ -373,7 +373,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 373 | 373 | Record<string, string> |
| 374 | 374 | >(() => readLastInvokedScriptByProjectFromStorage()); |
| 375 | 375 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| 376 | | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 377 | 376 | const shouldAutoScrollRef = useRef(true); |
| 378 | 377 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 379 | 378 | const composerCommandInputRef = useRef<HTMLInputElement>(null); |
| ... | ... | @@ -1985,6 +1984,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1985 | 1984 | <MessagesTimeline |
| 1986 | 1985 | hasMessages={activeThread.messages.length > 0} |
| 1987 | 1986 | isWorking={isWorking} |
| 1987 | scrollContainerRef={messagesScrollRef} |
| 1988 | 1988 | timelineEntries={timelineEntries} |
| 1989 | 1989 | completionDividerBeforeEntryId={completionDividerBeforeEntryId} |
| 1990 | 1990 | completionSummary={completionSummary} |
| ... | ... | @@ -1998,7 +1998,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1998 | 1998 | onRevertUserMessage={onRevertUserMessage} |
| 1999 | 1999 | isRevertingCheckpoint={isRevertingCheckpoint} |
| 2000 | 2000 | onImageExpand={onExpandTimelineImage} |
| 2001 | | messagesEndRef={messagesEndRef} |
| 2002 | 2001 | /> |
| 2003 | 2002 | </div> |
| 2004 | 2003 | |
| ... | ... | @@ -2450,6 +2449,7 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ |
| 2450 | 2449 | interface MessagesTimelineProps { |
| 2451 | 2450 | hasMessages: boolean; |
| 2452 | 2451 | isWorking: boolean; |
| 2452 | scrollContainerRef: RefObject<HTMLDivElement | null>; |
| 2453 | 2453 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; |
| 2454 | 2454 | completionDividerBeforeEntryId: string | null; |
| 2455 | 2455 | completionSummary: string | null; |
| ... | ... | @@ -2463,12 +2463,29 @@ interface MessagesTimelineProps { |
| 2463 | 2463 | onRevertUserMessage: (messageId: string) => void; |
| 2464 | 2464 | isRevertingCheckpoint: boolean; |
| 2465 | 2465 | onImageExpand: (image: ExpandedImagePreview) => void; |
| 2466 | | messagesEndRef: RefObject<HTMLDivElement | null>; |
| 2467 | 2466 | } |
| 2468 | 2467 | |
| 2468 | type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number]; |
| 2469 | type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"]; |
| 2470 | type TimelineWorkEntry = Extract<TimelineEntry, { kind: "work" }>["entry"]; |
| 2471 | type 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 | |
| 2469 | 2485 | const MessagesTimeline = memo(function MessagesTimeline({ |
| 2470 | 2486 | hasMessages, |
| 2471 | 2487 | isWorking, |
| 2488 | scrollContainerRef, |
| 2472 | 2489 | timelineEntries, |
| 2473 | 2490 | completionDividerBeforeEntryId, |
| 2474 | 2491 | completionSummary, |
| ... | ... | @@ -2482,8 +2499,67 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2482 | 2499 | onRevertUserMessage, |
| 2483 | 2500 | isRevertingCheckpoint, |
| 2484 | 2501 | onImageExpand, |
| 2485 | | messagesEndRef, |
| 2486 | 2502 | }: 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 | |
| 2487 | 2563 | if (!hasMessages && !isWorking) { |
| 2488 | 2564 | return ( |
| 2489 | 2565 | <div className="flex h-full items-center justify-center"> |
| ... | ... | @@ -2495,296 +2571,316 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2495 | 2571 | } |
| 2496 | 2572 | |
| 2497 | 2573 | 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; |
| 2640 | 2581 | |
| 2641 | 2582 | 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 && ( |
| 2723 | 2618 | <button |
| 2724 | | key={`${turnSummary.turnId}:${file.path}`} |
| 2725 | 2619 | 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" |
| 2728 | 2632 | > |
| 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 ? ( |
| 2741 | 2638 | <> |
| 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> |
| 2748 | 2646 | </> |
| 2749 | | ); |
| 2750 | | })()} |
| 2751 | | </button> |
| 2647 | ) : ( |
| 2648 | workEntry.label |
| 2649 | )} |
| 2650 | </p> |
| 2651 | </div> |
| 2752 | 2652 | ))} |
| 2753 | 2653 | </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> |
| 2757 | 2877 | </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 | )} |
| 2771 | 2880 | </div> |
| 2772 | | </Fragment> |
| 2881 | </div> |
| 2773 | 2882 | ); |
| 2774 | 2883 | })} |
| 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} /> |
| 2788 | 2884 | </div> |
| 2789 | 2885 | ); |
| 2790 | 2886 | }); |