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 {
2626 useRef,
2727 useState,
2828} from "react";
29import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
29import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
3030import { useDebouncedValue } from "@tanstack/react-pacer";
3131import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual";
3232import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
33import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
3334import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
3435import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
3536
......@@ -51,6 +52,7 @@ import {
5152import {
5253 derivePendingApprovals,
5354 derivePhase,
55 deriveTurnDiffFilesFromUnifiedDiff,
5456 deriveTimelineEntries,
5557 type TurnDiffSummary,
5658 type PendingApproval,
......@@ -66,6 +68,7 @@ import {
6668 DEFAULT_THREAD_TERMINAL_ID,
6769 MAX_THREAD_TERMINAL_COUNT,
6870 type ChatImageAttachment,
71 type TurnDiffFileChange,
6972} from "../types";
7073import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
7174import { useTheme } from "../hooks/useTheme";
......@@ -180,6 +183,33 @@ interface ExpandedImagePreview {
180183 name: string;
181184}
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
183213type ComposerCommandItem =
184214 | {
185215 id: string;
......@@ -428,6 +458,69 @@ export default function ChatView({ threadId }: ChatViewProps) {
428458 }
429459 return byMessageId;
430460 }, [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]);
431524 const revertTurnCountByUserMessageId = useMemo(() => {
432525 const byUserMessageId = new Map<string, number>();
433526 for (let index = 0; index < timelineEntries.length; index += 1) {
......@@ -1967,6 +2060,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
19672060 completionSummary={completionSummary}
19682061 assistantCompletionByItemId={assistantCompletionByItemId}
19692062 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
2063 turnCheckpointFilesByTurnId={turnCheckpointFilesByTurnId}
19702064 nowIso={nowIso}
19712065 expandedWorkGroups={expandedWorkGroups}
19722066 onToggleWorkGroup={onToggleWorkGroup}
......@@ -2432,6 +2526,7 @@ interface MessagesTimelineProps {
24322526 completionSummary: string | null;
24332527 assistantCompletionByItemId: Map<string, string>;
24342528 turnDiffSummaryByAssistantMessageId: Map<string, TurnDiffSummary>;
2529 turnCheckpointFilesByTurnId: Map<string, TurnCheckpointFilesState>;
24352530 nowIso: string;
24362531 expandedWorkGroups: Record<string, boolean>;
24372532 onToggleWorkGroup: (groupId: string) => void;
......@@ -2468,6 +2563,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
24682563 completionSummary,
24692564 assistantCompletionByItemId,
24702565 turnDiffSummaryByAssistantMessageId,
2566 turnCheckpointFilesByTurnId,
24712567 nowIso,
24722568 expandedWorkGroups,
24732569 onToggleWorkGroup,
......@@ -2716,7 +2812,9 @@ const MessagesTimeline = memo(function MessagesTimeline({
27162812 row.message.id,
27172813 );
27182814 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(
27202818 (acc, file) => {
27212819 if (typeof file.additions !== "number" || typeof file.deletions !== "number") {
27222820 return acc;
......@@ -2728,7 +2826,8 @@ const MessagesTimeline = memo(function MessagesTimeline({
27282826 },
27292827 { additions: 0, deletions: 0 },
27302828 );
2731 const changedFileCountLabel = String(turnSummary.files.length);
2829 const changedFileCountLabel =
2830 checkpointFilesState?.status === "ready" ? String(checkpointFiles.length) : "...";
27322831 return (
27332832 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
27342833 <div className="mb-1.5 flex items-center justify-between gap-2">
......@@ -2747,16 +2846,29 @@ const MessagesTimeline = memo(function MessagesTimeline({
27472846 type="button"
27482847 size="xs"
27492848 variant="outline"
2750 onClick={() =>
2751 onOpenTurnDiff(turnSummary.turnId, turnSummary.files[0]?.path)
2752 }
2849 onClick={() => onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)}
27532850 >
27542851 View diff
27552852 </Button>
27562853 </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 ? (
27582870 <div className="flex flex-wrap gap-1.5">
2759 {turnSummary.files.map((file) => (
2871 {checkpointFiles.map((file) => (
27602872 <button
27612873 key={`${turnSummary.turnId}:${file.path}`}
27622874 type="button"
apps/web/src/store.test.ts+39
......@@ -980,4 +980,43 @@ describe("store reducer thread continuity", () => {
980980 expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(2);
981981 expect(next.threads[0]?.turnDiffSummaries[1]?.checkpointTurnCount).toBe(3);
982982 });
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 });
9831022});
apps/web/src/store.ts+12-1
......@@ -891,7 +891,18 @@ export function reducer(state: AppState, action: Action): AppState {
891891 ...state,
892892 threads: updateThread(state.threads, target.id, (t) => {
893893 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);
895906 const turnDiffSummaries = shouldRederiveDiffs
896907 ? mergeTurnDiffSummaries(t.turnDiffSummaries, deriveTurnDiffSummaries(nextEvents))
897908 : t.turnDiffSummaries;