| ... | @@ -16,7 +16,6 @@ import { | ... | @@ -16,7 +16,6 @@ import { |
| 16 | type ClipboardEvent, | 16 | type ClipboardEvent, |
| 17 | type DragEvent, | 17 | type DragEvent, |
| 18 | type FormEvent, | 18 | type FormEvent, |
| 19 | Fragment, | | |
| 20 | type KeyboardEvent, | 19 | type KeyboardEvent, |
| 21 | memo, | 20 | memo, |
| 22 | type RefObject, | 21 | type RefObject, |
| ... | @@ -29,6 +28,7 @@ import { | ... | @@ -29,6 +28,7 @@ import { |
| 29 | } from "react"; | 28 | } from "react"; |
| 30 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; | 29 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 31 | import { useDebouncedValue } from "@tanstack/react-pacer"; | 30 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| | 31 | import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; |
| 32 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; | 32 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; |
| 33 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; | 33 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 34 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; | 34 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| ... | @@ -373,7 +373,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -373,7 +373,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 373 | Record<string, string> | 373 | Record<string, string> |
| 374 | >(() => readLastInvokedScriptByProjectFromStorage()); | 374 | >(() => readLastInvokedScriptByProjectFromStorage()); |
| 375 | const messagesScrollRef = useRef<HTMLDivElement>(null); | 375 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| 376 | const messagesEndRef = useRef<HTMLDivElement>(null); | | |
| 377 | const shouldAutoScrollRef = useRef(true); | 376 | const shouldAutoScrollRef = useRef(true); |
| 378 | const textareaRef = useRef<HTMLTextAreaElement>(null); | 377 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 379 | const composerCommandInputRef = useRef<HTMLInputElement>(null); | 378 | const composerCommandInputRef = useRef<HTMLInputElement>(null); |
| ... | @@ -1985,6 +1984,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1985,6 +1984,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1985 | <MessagesTimeline | 1984 | <MessagesTimeline |
| 1986 | hasMessages={activeThread.messages.length > 0} | 1985 | hasMessages={activeThread.messages.length > 0} |
| 1987 | isWorking={isWorking} | 1986 | isWorking={isWorking} |
| | 1987 | scrollContainerRef={messagesScrollRef} |
| 1988 | timelineEntries={timelineEntries} | 1988 | timelineEntries={timelineEntries} |
| 1989 | completionDividerBeforeEntryId={completionDividerBeforeEntryId} | 1989 | completionDividerBeforeEntryId={completionDividerBeforeEntryId} |
| 1990 | completionSummary={completionSummary} | 1990 | completionSummary={completionSummary} |
| ... | @@ -1998,7 +1998,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1998,7 +1998,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1998 | onRevertUserMessage={onRevertUserMessage} | 1998 | onRevertUserMessage={onRevertUserMessage} |
| 1999 | isRevertingCheckpoint={isRevertingCheckpoint} | 1999 | isRevertingCheckpoint={isRevertingCheckpoint} |
| 2000 | onImageExpand={onExpandTimelineImage} | 2000 | onImageExpand={onExpandTimelineImage} |
| 2001 | messagesEndRef={messagesEndRef} | | |
| 2002 | /> | 2001 | /> |
| 2003 | </div> | 2002 | </div> |
| 2004 | | 2003 | |
| ... | @@ -2450,6 +2449,7 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ | ... | @@ -2450,6 +2449,7 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ |
| 2450 | interface MessagesTimelineProps { | 2449 | interface MessagesTimelineProps { |
| 2451 | hasMessages: boolean; | 2450 | hasMessages: boolean; |
| 2452 | isWorking: boolean; | 2451 | isWorking: boolean; |
| | 2452 | scrollContainerRef: RefObject<HTMLDivElement | null>; |
| 2453 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; | 2453 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; |
| 2454 | completionDividerBeforeEntryId: string | null; | 2454 | completionDividerBeforeEntryId: string | null; |
| 2455 | completionSummary: string | null; | 2455 | completionSummary: string | null; |
| ... | @@ -2463,12 +2463,29 @@ interface MessagesTimelineProps { | ... | @@ -2463,12 +2463,29 @@ interface MessagesTimelineProps { |
| 2463 | onRevertUserMessage: (messageId: string) => void; | 2463 | onRevertUserMessage: (messageId: string) => void; |
| 2464 | isRevertingCheckpoint: boolean; | 2464 | isRevertingCheckpoint: boolean; |
| 2465 | onImageExpand: (image: ExpandedImagePreview) => void; | 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 | const MessagesTimeline = memo(function MessagesTimeline({ | 2485 | const MessagesTimeline = memo(function MessagesTimeline({ |
| 2470 | hasMessages, | 2486 | hasMessages, |
| 2471 | isWorking, | 2487 | isWorking, |
| | 2488 | scrollContainerRef, |
| 2472 | timelineEntries, | 2489 | timelineEntries, |
| 2473 | completionDividerBeforeEntryId, | 2490 | completionDividerBeforeEntryId, |
| 2474 | completionSummary, | 2491 | completionSummary, |
| ... | @@ -2482,8 +2499,67 @@ const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -2482,8 +2499,67 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2482 | onRevertUserMessage, | 2499 | onRevertUserMessage, |
| 2483 | isRevertingCheckpoint, | 2500 | isRevertingCheckpoint, |
| 2484 | onImageExpand, | 2501 | onImageExpand, |
| 2485 | messagesEndRef, | | |
| 2486 | }: MessagesTimelineProps) { | 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 | if (!hasMessages && !isWorking) { | 2563 | if (!hasMessages && !isWorking) { |
| 2488 | return ( | 2564 | return ( |
| 2489 | <div className="flex h-full items-center justify-center"> | 2565 | <div className="flex h-full items-center justify-center"> |
| ... | @@ -2495,296 +2571,316 @@ const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -2495,296 +2571,316 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2495 | } | 2571 | } |
| 2496 | | 2572 | |
| 2497 | return ( | 2573 | return ( |
| 2498 | <div className="mx-auto max-w-3xl space-y-4"> | 2574 | <div |
| 2499 | {timelineEntries.map((timelineEntry, index) => { | 2575 | className="relative mx-auto max-w-3xl" |
| 2500 | if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") { | 2576 | style={{ height: `${rowVirtualizer.getTotalSize()}px` }} |
| 2501 | return null; | 2577 | > |
| 2502 | } | 2578 | {virtualRows.map((virtualRow: VirtualItem) => { |
| 2503 | | 2579 | const row = rows[virtualRow.index]; |
| 2504 | const showCompletionDivider = | 2580 | if (!row) return null; |
| 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 | } | | |
| 2640 | | 2581 | |
| 2641 | return ( | 2582 | return ( |
| 2642 | <Fragment key={timelineEntry.id}> | 2583 | <div |
| 2643 | {showCompletionDivider && ( | 2584 | key={row.id} |
| 2644 | <div className="my-3 flex items-center gap-3"> | 2585 | data-index={virtualRow.index} |
| 2645 | <span className="h-px flex-1 bg-border" /> | 2586 | ref={rowVirtualizer.measureElement} |
| 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"> | 2587 | className="absolute left-0 top-0 w-full" |
| 2647 | {completionSummary ? `Response • ${completionSummary}` : "Response"} | 2588 | style={{ transform: `translateY(${virtualRow.start}px)` }} |
| 2648 | </span> | 2589 | > |
| 2649 | <span className="h-px flex-1 bg-border" /> | 2590 | <div className="pb-4"> |
| 2650 | </div> | 2591 | {row.kind === "work" && |
| 2651 | )} | 2592 | (() => { |
| 2652 | <div className="px-1 py-0.5"> | 2593 | const groupId = row.id; |
| 2653 | <ChatMarkdown | 2594 | const groupedEntries = row.groupedEntries; |
| 2654 | text={ | 2595 | const isExpanded = expandedWorkGroups[groupId] ?? false; |
| 2655 | timelineEntry.message.text || | 2596 | const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; |
| 2656 | (timelineEntry.message.streaming ? "" : "(empty response)") | 2597 | const visibleEntries = |
| 2657 | } | 2598 | hasOverflow && !isExpanded |
| 2658 | /> | 2599 | ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) |
| 2659 | {(() => { | 2600 | : groupedEntries; |
| 2660 | const turnSummary = turnDiffSummaryByAssistantMessageId.get( | 2601 | const hiddenCount = groupedEntries.length - visibleEntries.length; |
| 2661 | timelineEntry.message.id, | 2602 | const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); |
| 2662 | ); | 2603 | const groupLabel = onlyToolEntries |
| 2663 | if (!turnSummary) return null; | 2604 | ? groupedEntries.length === 1 |
| 2664 | const isCheckpointDiffLoading = | 2605 | ? "Tool call" |
| 2665 | !turnSummary.checkpointDiffLoaded && turnSummary.files.length === 0; | 2606 | : `Tool calls (${groupedEntries.length})` |
| 2666 | const summaryStat = turnSummary.unifiedDiff | 2607 | : groupedEntries.length === 1 |
| 2667 | ? countDiffStat(turnSummary.unifiedDiff) | 2608 | ? "Work event" |
| 2668 | : turnSummary.files.reduce( | 2609 | : `Work log (${groupedEntries.length})`; |
| 2669 | (acc, file) => { | 2610 | |
| 2670 | const next = | 2611 | return ( |
| 2671 | typeof file.additions === "number" && typeof file.deletions === "number" | 2612 | <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2"> |
| 2672 | ? { additions: file.additions, deletions: file.deletions } | 2613 | <div className="mb-1.5 flex items-center justify-between gap-3"> |
| 2673 | : file.diff | 2614 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> |
| 2674 | ? countDiffStat(file.diff) | 2615 | {groupLabel} |
| 2675 | : null; | 2616 | </p> |
| 2676 | if (!next) { | 2617 | {hasOverflow && ( |
| 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) => ( | | |
| 2723 | <button | 2618 | <button |
| 2724 | key={`${turnSummary.turnId}:${file.path}`} | | |
| 2725 | type="button" | 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" | 2620 | className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80" |
| 2727 | onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)} | 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 | {(() => { | 2633 | <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> |
| 2730 | const stat = | 2634 | <p |
| 2731 | typeof file.additions === "number" && | 2635 | className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`} |
| 2732 | typeof file.deletions === "number" | 2636 | > |
| 2733 | ? { additions: file.additions, deletions: file.deletions } | 2637 | {workEntry.detail ? ( |
| 2734 | : file.diff | | |
| 2735 | ? countDiffStat(file.diff) | | |
| 2736 | : null; | | |
| 2737 | if (!stat) { | | |
| 2738 | return file.path; | | |
| 2739 | } | | |
| 2740 | return ( | | |
| 2741 | <> | 2638 | <> |
| 2742 | <span>{file.path}</span> | 2639 | {workEntry.label} |
| 2743 | <span className="ml-1 text-muted-foreground/70">(</span> | 2640 | <span |
| 2744 | <span className="text-success">+{stat.additions}</span> | 2641 | className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60" |
| 2745 | <span className="mx-0.5 text-muted-foreground/70">/</span> | 2642 | title={workEntry.detail} |
| 2746 | <span className="text-destructive">-{stat.deletions}</span> | 2643 | > |
| 2747 | <span className="text-muted-foreground/70">)</span> | 2644 | {workEntry.detail} |
| | 2645 | </span> |
| 2748 | </> | 2646 | </> |
| 2749 | ); | 2647 | ) : ( |
| 2750 | })()} | 2648 | workEntry.label |
| 2751 | </button> | 2649 | )} |
| | 2650 | </p> |
| | 2651 | </div> |
| 2752 | ))} | 2652 | ))} |
| 2753 | </div> | 2653 | </div> |
| 2754 | ) : !isCheckpointDiffLoading ? ( | 2654 | </div> |
| 2755 | <p className="text-[11px] text-muted-foreground/70">No changed files.</p> | 2655 | ); |
| 2756 | ) : null} | 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 | </div> | 2877 | </div> |
| 2758 | ); | 2878 | </div> |
| 2759 | })()} | 2879 | )} |
| 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> | | |
| 2771 | </div> | 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 | </div> | 2884 | </div> |
| 2789 | ); | 2885 | ); |
| 2790 | }); | 2886 | }); |