authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-18 23:41:46-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-19 00:35:32-08:00
log22e1a4cf92e035c11d079e7254334acb4d7e2a35
treee1b3c47f6c9c871a240439b0a0b5c6553eb3a24e
parent79382b272c02f3a16e4ea77c3c67103d4c14414e

Use checkpoint diffs for turn files and ignore late summary rederives

- Load per-turn checkpoint diffs in `ChatView` and show loading/error states in the timeline - Drive changed-file chips/stats from checkpoint diff results instead of cached turn summary files - Prevent reducer from rederiving completed turn summaries on late `turn/diff/updated` events; add regression test

3 files changed, 171 insertions(+), 9 deletions(-)

apps/web/src/components/ChatView.tsx+120-8
...@@ -26,10 +26,11 @@ import {...@@ -26,10 +26,11 @@ import {
26 useRef,26 useRef,
27 useState,27 useState,
28} from "react";28} from "react";
29import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";29import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
30import { useDebouncedValue } from "@tanstack/react-pacer";30import { useDebouncedValue } from "@tanstack/react-pacer";
31import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual";31import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual";
32import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";32import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
33import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
33import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";34import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
34import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";35import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
3536
...@@ -51,6 +52,7 @@ import {...@@ -51,6 +52,7 @@ import {
51import {52import {
52 derivePendingApprovals,53 derivePendingApprovals,
53 derivePhase,54 derivePhase,
55 deriveTurnDiffFilesFromUnifiedDiff,
54 deriveTimelineEntries,56 deriveTimelineEntries,
55 type TurnDiffSummary,57 type TurnDiffSummary,
56 type PendingApproval,58 type PendingApproval,
...@@ -66,6 +68,7 @@ import {...@@ -66,6 +68,7 @@ import {
66 DEFAULT_THREAD_TERMINAL_ID,68 DEFAULT_THREAD_TERMINAL_ID,
67 MAX_THREAD_TERMINAL_COUNT,69 MAX_THREAD_TERMINAL_COUNT,
68 type ChatImageAttachment,70 type ChatImageAttachment,
71 type TurnDiffFileChange,
69} from "../types";72} from "../types";
70import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";73import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
71import { useTheme } from "../hooks/useTheme";74import { useTheme } from "../hooks/useTheme";
...@@ -180,6 +183,33 @@ interface ExpandedImagePreview {...@@ -180,6 +183,33 @@ interface ExpandedImagePreview {
180 name: string;183 name: string;
181}184}
182185
186type 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
203function 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
183type ComposerCommandItem =213type ComposerCommandItem =
184 | {214 | {
185 id: string;215 id: string;
...@@ -428,6 +458,69 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -428,6 +458,69 @@ export default function ChatView({ threadId }: ChatViewProps) {
428 }458 }
429 return byMessageId;459 return byMessageId;
430 }, [turnDiffSummaries]);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 const revertTurnCountByUserMessageId = useMemo(() => {524 const revertTurnCountByUserMessageId = useMemo(() => {
432 const byUserMessageId = new Map<string, number>();525 const byUserMessageId = new Map<string, number>();
433 for (let index = 0; index < timelineEntries.length; index += 1) {526 for (let index = 0; index < timelineEntries.length; index += 1) {
...@@ -1967,6 +2060,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1967,6 +2060,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1967 completionSummary={completionSummary}2060 completionSummary={completionSummary}
1968 assistantCompletionByItemId={assistantCompletionByItemId}2061 assistantCompletionByItemId={assistantCompletionByItemId}
1969 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}2062 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
2063 turnCheckpointFilesByTurnId={turnCheckpointFilesByTurnId}
1970 nowIso={nowIso}2064 nowIso={nowIso}
1971 expandedWorkGroups={expandedWorkGroups}2065 expandedWorkGroups={expandedWorkGroups}
1972 onToggleWorkGroup={onToggleWorkGroup}2066 onToggleWorkGroup={onToggleWorkGroup}
...@@ -2432,6 +2526,7 @@ interface MessagesTimelineProps {...@@ -2432,6 +2526,7 @@ interface MessagesTimelineProps {
2432 completionSummary: string | null;2526 completionSummary: string | null;
2433 assistantCompletionByItemId: Map<string, string>;2527 assistantCompletionByItemId: Map<string, string>;
2434 turnDiffSummaryByAssistantMessageId: Map<string, TurnDiffSummary>;2528 turnDiffSummaryByAssistantMessageId: Map<string, TurnDiffSummary>;
2529 turnCheckpointFilesByTurnId: Map<string, TurnCheckpointFilesState>;
2435 nowIso: string;2530 nowIso: string;
2436 expandedWorkGroups: Record<string, boolean>;2531 expandedWorkGroups: Record<string, boolean>;
2437 onToggleWorkGroup: (groupId: string) => void;2532 onToggleWorkGroup: (groupId: string) => void;
...@@ -2468,6 +2563,7 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2468,6 +2563,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
2468 completionSummary,2563 completionSummary,
2469 assistantCompletionByItemId,2564 assistantCompletionByItemId,
2470 turnDiffSummaryByAssistantMessageId,2565 turnDiffSummaryByAssistantMessageId,
2566 turnCheckpointFilesByTurnId,
2471 nowIso,2567 nowIso,
2472 expandedWorkGroups,2568 expandedWorkGroups,
2473 onToggleWorkGroup,2569 onToggleWorkGroup,
...@@ -2716,7 +2812,9 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2716,7 +2812,9 @@ const MessagesTimeline = memo(function MessagesTimeline({
2716 row.message.id,2812 row.message.id,
2717 );2813 );
2718 if (!turnSummary) return null;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 (acc, file) => {2818 (acc, file) => {
2721 if (typeof file.additions !== "number" || typeof file.deletions !== "number") {2819 if (typeof file.additions !== "number" || typeof file.deletions !== "number") {
2722 return acc;2820 return acc;
...@@ -2728,7 +2826,8 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2728,7 +2826,8 @@ const MessagesTimeline = memo(function MessagesTimeline({
2728 },2826 },
2729 { additions: 0, deletions: 0 },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 return (2831 return (
2733 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">2832 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
2734 <div className="mb-1.5 flex items-center justify-between gap-2">2833 <div className="mb-1.5 flex items-center justify-between gap-2">
...@@ -2747,16 +2846,29 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2747,16 +2846,29 @@ const MessagesTimeline = memo(function MessagesTimeline({
2747 type="button"2846 type="button"
2748 size="xs"2847 size="xs"
2749 variant="outline"2848 variant="outline"
2750 onClick={() =>2849 onClick={() => onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)}
2751 onOpenTurnDiff(turnSummary.turnId, turnSummary.files[0]?.path)
2752 }
2753 >2850 >
2754 View diff2851 View diff
2755 </Button>2852 </Button>
2756 </div>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 <div className="flex flex-wrap gap-1.5">2870 <div className="flex flex-wrap gap-1.5">
2759 {turnSummary.files.map((file) => (2871 {checkpointFiles.map((file) => (
2760 <button2872 <button
2761 key={`${turnSummary.turnId}:${file.path}`}2873 key={`${turnSummary.turnId}:${file.path}`}
2762 type="button"2874 type="button"
apps/web/src/store.test.ts+39
...@@ -980,4 +980,43 @@ describe("store reducer thread continuity", () => {...@@ -980,4 +980,43 @@ describe("store reducer thread continuity", () => {
980 expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(2);980 expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(2);
981 expect(next.threads[0]?.turnDiffSummaries[1]?.checkpointTurnCount).toBe(3);981 expect(next.threads[0]?.turnDiffSummaries[1]?.checkpointTurnCount).toBe(3);
982 });982 });
983
984 it("does not rederive completed turn summaries from late turn diff events", () => {
985 const completed = reducer(makeState(makeThread()), {
986 type: "APPLY_EVENT",
987 event: makeEvent({
988 method: "turn/completed",
989 turnId: "turn-1",
990 createdAt: "2026-02-09T00:00:03.000Z",
991 payload: { turn: { id: "turn-1", status: "completed" } },
992 }),
993 activeAssistantItemRef: { current: null },
994 });
995
996 expect(completed.threads[0]?.turnDiffSummaries[0]?.files).toEqual([]);
997
998 const withLateDiff = reducer(completed, {
999 type: "APPLY_EVENT",
1000 event: makeEvent({
1001 method: "turn/diff/updated",
1002 turnId: "turn-1",
1003 createdAt: "2026-02-09T00:00:04.000Z",
1004 payload: {
1005 diff: [
1006 "diff --git a/src/a.ts b/src/a.ts",
1007 "@@ -1 +1 @@",
1008 "-old",
1009 "+new",
1010 "diff --git a/src/b.ts b/src/b.ts",
1011 "@@ -1 +1 @@",
1012 "-old",
1013 "+new",
1014 ].join("\n"),
1015 },
1016 }),
1017 activeAssistantItemRef: { current: null },
1018 });
1019
1020 expect(withLateDiff.threads[0]?.turnDiffSummaries[0]?.files).toEqual([]);
1021 });
983});1022});
apps/web/src/store.ts+12-1
...@@ -891,7 +891,18 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -891,7 +891,18 @@ export function reducer(state: AppState, action: Action): AppState {
891 ...state,891 ...state,
892 threads: updateThread(state.threads, target.id, (t) => {892 threads: updateThread(state.threads, target.id, (t) => {
893 const nextEvents = [event, ...t.events];893 const nextEvents = [event, ...t.events];
894 const shouldRederiveDiffs = event.method === "turn/completed";894 const eventTurnId = getEventTurnId(event);
895 const hasCompletedSummaryForTurn = Boolean(
896 eventTurnId && t.turnDiffSummaries.some((summary) => summary.turnId === eventTurnId),
897 );
898 const itemType = asString(asObject(asObject(event.payload)?.item)?.type);
899 const normalizedItemType = itemType?.replace(/[_-]/g, "").toLowerCase();
900 const isMetadataItemCompleted =
901 event.method === "item/completed" &&
902 (normalizedItemType === "agentmessage" || normalizedItemType === "filechange");
903 const shouldRederiveDiffs =
904 event.method === "turn/completed" ||
905 (hasCompletedSummaryForTurn && isMetadataItemCompleted);
895 const turnDiffSummaries = shouldRederiveDiffs906 const turnDiffSummaries = shouldRederiveDiffs
896 ? mergeTurnDiffSummaries(t.turnDiffSummaries, deriveTurnDiffSummaries(nextEvents))907 ? mergeTurnDiffSummaries(t.turnDiffSummaries, deriveTurnDiffSummaries(nextEvents))
897 : t.turnDiffSummaries;908 : t.turnDiffSummaries;