| ... | ... | @@ -26,10 +26,11 @@ import { |
| 26 | 26 | useRef, |
| 27 | 27 | useState, |
| 28 | 28 | } from "react"; |
| 29 | | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 29 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 30 | 30 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 31 | 31 | import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; |
| 32 | 32 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; |
| 33 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 33 | 34 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 34 | 35 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; |
| 35 | 36 | |
| ... | ... | @@ -51,6 +52,7 @@ import { |
| 51 | 52 | import { |
| 52 | 53 | derivePendingApprovals, |
| 53 | 54 | derivePhase, |
| 55 | deriveTurnDiffFilesFromUnifiedDiff, |
| 54 | 56 | deriveTimelineEntries, |
| 55 | 57 | type TurnDiffSummary, |
| 56 | 58 | type PendingApproval, |
| ... | ... | @@ -66,6 +68,7 @@ import { |
| 66 | 68 | DEFAULT_THREAD_TERMINAL_ID, |
| 67 | 69 | MAX_THREAD_TERMINAL_COUNT, |
| 68 | 70 | type ChatImageAttachment, |
| 71 | type TurnDiffFileChange, |
| 69 | 72 | } from "../types"; |
| 70 | 73 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; |
| 71 | 74 | import { useTheme } from "../hooks/useTheme"; |
| ... | ... | @@ -180,6 +183,33 @@ interface ExpandedImagePreview { |
| 180 | 183 | name: string; |
| 181 | 184 | } |
| 182 | 185 | |
| 186 | type TurnCheckpointFilesState = |
| 187 | | { |
| 188 | status: "pending" | "loading"; |
| 189 | files: TurnDiffFileChange[]; |
| 190 | errorMessage?: undefined; |
| 191 | } |
| 192 | | { |
| 193 | status: "error"; |
| 194 | files: TurnDiffFileChange[]; |
| 195 | errorMessage: string; |
| 196 | } |
| 197 | | { |
| 198 | status: "ready"; |
| 199 | files: TurnDiffFileChange[]; |
| 200 | errorMessage?: undefined; |
| 201 | }; |
| 202 | |
| 203 | function checkpointErrorMessage(error: unknown): string { |
| 204 | if (error instanceof Error && error.message.trim().length > 0) { |
| 205 | return error.message; |
| 206 | } |
| 207 | if (typeof error === "string" && error.trim().length > 0) { |
| 208 | return error; |
| 209 | } |
| 210 | return "Checkpoint diff is unavailable."; |
| 211 | } |
| 212 | |
| 183 | 213 | type ComposerCommandItem = |
| 184 | 214 | | { |
| 185 | 215 | id: string; |
| ... | ... | @@ -428,6 +458,69 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 428 | 458 | } |
| 429 | 459 | return byMessageId; |
| 430 | 460 | }, [turnDiffSummaries]); |
| 461 | const checkpointDiffInputsByTurn = useMemo( |
| 462 | () => |
| 463 | turnDiffSummaries |
| 464 | .filter((summary) => summary.assistantMessageId) |
| 465 | .map((summary) => { |
| 466 | const checkpointTurnCount = |
| 467 | summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; |
| 468 | return { |
| 469 | turnId: summary.turnId, |
| 470 | checkpointTurnCount: |
| 471 | typeof checkpointTurnCount === "number" ? checkpointTurnCount : null, |
| 472 | }; |
| 473 | }), |
| 474 | [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], |
| 475 | ); |
| 476 | const checkpointDiffQueriesByTurn = useQueries({ |
| 477 | queries: checkpointDiffInputsByTurn.map((input) => |
| 478 | checkpointDiffQueryOptions(api, { |
| 479 | sessionId: activeSessionId, |
| 480 | threadRuntimeId: activeThreadRuntimeId, |
| 481 | fromTurnCount: |
| 482 | typeof input.checkpointTurnCount === "number" |
| 483 | ? Math.max(0, input.checkpointTurnCount - 1) |
| 484 | : null, |
| 485 | toTurnCount: input.checkpointTurnCount, |
| 486 | cacheScope: `turn:${input.turnId}`, |
| 487 | }), |
| 488 | ), |
| 489 | }); |
| 490 | const turnCheckpointFilesByTurnId = useMemo(() => { |
| 491 | const byTurnId = new Map<string, TurnCheckpointFilesState>(); |
| 492 | for (let index = 0; index < checkpointDiffInputsByTurn.length; index += 1) { |
| 493 | const input = checkpointDiffInputsByTurn[index]; |
| 494 | const query = checkpointDiffQueriesByTurn[index]; |
| 495 | if (!input || !query) continue; |
| 496 | |
| 497 | if (!activeSessionId || typeof input.checkpointTurnCount !== "number") { |
| 498 | byTurnId.set(input.turnId, { status: "pending", files: [] }); |
| 499 | continue; |
| 500 | } |
| 501 | |
| 502 | const diff = query.data?.diff; |
| 503 | if (typeof diff === "string") { |
| 504 | byTurnId.set(input.turnId, { |
| 505 | status: "ready", |
| 506 | files: deriveTurnDiffFilesFromUnifiedDiff(diff), |
| 507 | }); |
| 508 | continue; |
| 509 | } |
| 510 | |
| 511 | if (query.isError) { |
| 512 | byTurnId.set(input.turnId, { |
| 513 | status: "error", |
| 514 | files: [], |
| 515 | errorMessage: checkpointErrorMessage(query.error), |
| 516 | }); |
| 517 | continue; |
| 518 | } |
| 519 | |
| 520 | byTurnId.set(input.turnId, { status: "loading", files: [] }); |
| 521 | } |
| 522 | return byTurnId; |
| 523 | }, [activeSessionId, checkpointDiffInputsByTurn, checkpointDiffQueriesByTurn]); |
| 431 | 524 | const revertTurnCountByUserMessageId = useMemo(() => { |
| 432 | 525 | const byUserMessageId = new Map<string, number>(); |
| 433 | 526 | for (let index = 0; index < timelineEntries.length; index += 1) { |
| ... | ... | @@ -1967,6 +2060,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1967 | 2060 | completionSummary={completionSummary} |
| 1968 | 2061 | assistantCompletionByItemId={assistantCompletionByItemId} |
| 1969 | 2062 | turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} |
| 2063 | turnCheckpointFilesByTurnId={turnCheckpointFilesByTurnId} |
| 1970 | 2064 | nowIso={nowIso} |
| 1971 | 2065 | expandedWorkGroups={expandedWorkGroups} |
| 1972 | 2066 | onToggleWorkGroup={onToggleWorkGroup} |
| ... | ... | @@ -2432,6 +2526,7 @@ interface MessagesTimelineProps { |
| 2432 | 2526 | completionSummary: string | null; |
| 2433 | 2527 | assistantCompletionByItemId: Map<string, string>; |
| 2434 | 2528 | turnDiffSummaryByAssistantMessageId: Map<string, TurnDiffSummary>; |
| 2529 | turnCheckpointFilesByTurnId: Map<string, TurnCheckpointFilesState>; |
| 2435 | 2530 | nowIso: string; |
| 2436 | 2531 | expandedWorkGroups: Record<string, boolean>; |
| 2437 | 2532 | onToggleWorkGroup: (groupId: string) => void; |
| ... | ... | @@ -2468,6 +2563,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2468 | 2563 | completionSummary, |
| 2469 | 2564 | assistantCompletionByItemId, |
| 2470 | 2565 | turnDiffSummaryByAssistantMessageId, |
| 2566 | turnCheckpointFilesByTurnId, |
| 2471 | 2567 | nowIso, |
| 2472 | 2568 | expandedWorkGroups, |
| 2473 | 2569 | onToggleWorkGroup, |
| ... | ... | @@ -2716,7 +2812,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2716 | 2812 | row.message.id, |
| 2717 | 2813 | ); |
| 2718 | 2814 | if (!turnSummary) return null; |
| 2719 | | const summaryStat = turnSummary.files.reduce( |
| 2815 | const checkpointFilesState = turnCheckpointFilesByTurnId.get(turnSummary.turnId); |
| 2816 | const checkpointFiles = checkpointFilesState?.files ?? []; |
| 2817 | const summaryStat = checkpointFiles.reduce( |
| 2720 | 2818 | (acc, file) => { |
| 2721 | 2819 | if (typeof file.additions !== "number" || typeof file.deletions !== "number") { |
| 2722 | 2820 | return acc; |
| ... | ... | @@ -2728,7 +2826,8 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2728 | 2826 | }, |
| 2729 | 2827 | { additions: 0, deletions: 0 }, |
| 2730 | 2828 | ); |
| 2731 | | const changedFileCountLabel = String(turnSummary.files.length); |
| 2829 | const changedFileCountLabel = |
| 2830 | checkpointFilesState?.status === "ready" ? String(checkpointFiles.length) : "..."; |
| 2732 | 2831 | return ( |
| 2733 | 2832 | <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> |
| 2734 | 2833 | <div className="mb-1.5 flex items-center justify-between gap-2"> |
| ... | ... | @@ -2747,16 +2846,29 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2747 | 2846 | type="button" |
| 2748 | 2847 | size="xs" |
| 2749 | 2848 | variant="outline" |
| 2750 | | onClick={() => |
| 2751 | | onOpenTurnDiff(turnSummary.turnId, turnSummary.files[0]?.path) |
| 2752 | | } |
| 2849 | onClick={() => onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)} |
| 2753 | 2850 | > |
| 2754 | 2851 | View diff |
| 2755 | 2852 | </Button> |
| 2756 | 2853 | </div> |
| 2757 | | {turnSummary.files.length > 0 ? ( |
| 2854 | {!checkpointFilesState || checkpointFilesState.status === "pending" ? ( |
| 2855 | <p className="text-[11px] text-muted-foreground/70"> |
| 2856 | Waiting for checkpoint metadata... |
| 2857 | </p> |
| 2858 | ) : checkpointFilesState?.status === "loading" ? ( |
| 2859 | <p className="text-[11px] text-muted-foreground/70"> |
| 2860 | Loading checkpoint diff... |
| 2861 | </p> |
| 2862 | ) : checkpointFilesState?.status === "error" ? ( |
| 2863 | <p |
| 2864 | className="truncate text-[11px] text-muted-foreground/70" |
| 2865 | title={checkpointFilesState.errorMessage} |
| 2866 | > |
| 2867 | {checkpointFilesState.errorMessage} |
| 2868 | </p> |
| 2869 | ) : checkpointFiles.length > 0 ? ( |
| 2758 | 2870 | <div className="flex flex-wrap gap-1.5"> |
| 2759 | | {turnSummary.files.map((file) => ( |
| 2871 | {checkpointFiles.map((file) => ( |
| 2760 | 2872 | <button |
| 2761 | 2873 | key={`${turnSummary.turnId}:${file.path}`} |
| 2762 | 2874 | type="button" |