Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3960,7 +3960,10 @@ export default function ChatView(props: ChatViewProps) {
};
if (diffAction !== "open" || newlyCompletedTurnId === null) return;

useDiffPanelStore.getState().selectTurn(activeThreadRef, newlyCompletedTurnId);
const diffSelection = useDiffPanelStore.getState().byThreadKey[activeThreadKey];
if (diffSelection?.kind !== "turn" || !diffSelection.baselineTurnId) {
useDiffPanelStore.getState().selectTurn(activeThreadRef, newlyCompletedTurnId);
}
useRightPanelStore.getState().open(activeThreadRef, "diff");
onDiffPanelOpen?.();
}, [
Expand Down
138 changes: 113 additions & 25 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ import { type DraftId } from "../composerDraftStore";
import { openDiffFilePrimaryAction } from "../diffFileActions";
import { useCheckpointDiff } from "~/lib/checkpointDiffState";
import { cn } from "~/lib/utils";
import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore";
import {
type DiffBaseline,
getCheckpointDiffRange,
THREAD_START_DIFF_BASELINE,
selectThreadDiffPanelSelection,
useDiffPanelStore,
} from "../diffPanelStore";
import { useLocalStorage } from "../hooks/useLocalStorage";
import { useTheme } from "../hooks/useTheme";
import {
Expand Down Expand Up @@ -193,14 +199,26 @@ export default function DiffPanel({
);

useEffect(() => {
if (!routeThreadRef || diffSelection.kind !== "turn") return;
if (!routeThreadRef || !activeThread || diffSelection.kind !== "turn") return;
useDiffPanelStore.getState().reconcileTurnSelection(
routeThreadRef,
orderedTurnDiffSummaries.map((summary) => summary.turnId),
);
}, [diffSelection, orderedTurnDiffSummaries, routeThreadRef]);
}, [activeThread, diffSelection, orderedTurnDiffSummaries, routeThreadRef]);

const selectedTurnId = diffSelection.kind === "turn" ? diffSelection.turnId : null;
const selectedTurnId =
diffSelection.kind === "turn"
? diffSelection.followLatest
? (orderedTurnDiffSummaries[0]?.turnId ?? diffSelection.turnId)
: diffSelection.turnId
: null;
const baselineTurnId = diffSelection.kind === "turn" ? diffSelection.baselineTurnId : undefined;
const baselineTurn = orderedTurnDiffSummaries.find(
(summary) => summary.turnId === baselineTurnId,
);
const baselineTurnCount =
baselineTurnId === THREAD_START_DIFF_BASELINE ? 0 : baselineTurn?.checkpointTurnCount;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium components/DiffPanel.tsx:220

Pinned comparisons produce the wrong result for imported summaries without a stored checkpointTurnCount: baselineTurnCount becomes undefined, so getCheckpointDiffRange falls back to the preceding-turn diff and the label/cache omit the pinned baseline. The Turn menu also enables older endpoints with an inferred count because undefined < baselineTurnCount is false, and selecting one then yields no patch. Use inferredCheckpointTurnCountByTurnId[turnId] as the fallback both when computing baselineTurnCount and when disabling menu entries.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/DiffPanel.tsx around line 220:

Pinned comparisons produce the wrong result for imported summaries without a stored `checkpointTurnCount`: `baselineTurnCount` becomes `undefined`, so `getCheckpointDiffRange` falls back to the preceding-turn diff and the label/cache omit the pinned baseline. The Turn menu also enables older endpoints with an inferred count because `undefined < baselineTurnCount` is false, and selecting one then yields no patch. Use `inferredCheckpointTurnCountByTurnId[turnId]` as the fallback both when computing `baselineTurnCount` and when disabling menu entries.

const followsLatest = diffSelection.kind === "turn" && diffSelection.followLatest === true;
const selectedGitScope = diffSelection.kind === "unstaged" ? "unstaged" : "branch";
const selectedBaseRef = diffSelection.kind === "branch" ? diffSelection.baseRef : null;
const selectedFilePath = diffSelection.kind === "turn" ? diffSelection.filePath : null;
Expand All @@ -215,15 +233,41 @@ export default function DiffPanel({
selectedTurn &&
(selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]);
const latestTurn = orderedTurnDiffSummaries[0];
const [baselineMenuOpen, setBaselineMenuOpen] = useState(false);
const firstCheckpoint = orderedTurnDiffSummaries.findLast(
(summary) => summary.status === "ready" && summary.checkpointTurnCount > 0,
);
// A completion checkpoint can exist without Turn 0 when Git was initialized mid-turn.
// Check the earliest available range only when the baseline menu is opened.
const initialCheckpointDiff = useCheckpointDiff(
{
environmentId: activeThread?.environmentId ?? null,
threadId: activeThread?.id ?? null,
fromTurnCount: 0,
toTurnCount: firstCheckpoint?.checkpointTurnCount ?? null,
ignoreWhitespace: true,
},
{ enabled: baselineMenuOpen && isGitRepo },
);
const isLatestTurnSelection =
baselineTurnCount === undefined ? selectedTurn?.turnId === latestTurn?.turnId : followsLatest;
const selectedScopeLabel =
selectedTurnId === null
? selectedGitScope === "unstaged"
? "Working tree"
: "Branch changes"
: selectedTurn?.turnId === latestTurn?.turnId
: isLatestTurnSelection
? "Latest turn"
: `Turn ${selectedCheckpointTurnCount ?? "?"}`;
const reviewSectionId = selectedTurn ? `turn:${selectedTurn.turnId}` : selectedGitScope;
const comparisonLabel =
baselineTurnCount !== undefined
? `Turn ${baselineTurnCount} → ${selectedScopeLabel}`
: selectedScopeLabel;
const reviewSectionId = selectedTurn
? baselineTurnCount !== undefined
? `turn:${baselineTurnId}:${selectedTurn.turnId}`
: `turn:${selectedTurn.turnId}`
: selectedGitScope;
const collapseScopeKey = routeThreadRef
? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}`
: null;
Expand All @@ -233,19 +277,15 @@ export default function DiffPanel({
? collapsedDiffFiles.fileKeys
: EMPTY_COLLAPSED_DIFF_FILE_KEYS;
const reviewSectionTitle = selectedTurn
? `Turn ${selectedCheckpointTurnCount ?? "?"}`
? baselineTurnCount !== undefined
? comparisonLabel
: `Turn ${selectedCheckpointTurnCount ?? "?"}`
: selectedGitScope === "unstaged"
? "Working tree"
: "Branch changes";
const selectedCheckpointRange = useMemo(
() =>
typeof selectedCheckpointTurnCount === "number"
? {
fromTurnCount: Math.max(0, selectedCheckpointTurnCount - 1),
toTurnCount: selectedCheckpointTurnCount,
}
: null,
[selectedCheckpointTurnCount],
const selectedCheckpointRange = getCheckpointDiffRange(
selectedCheckpointTurnCount,
baselineTurnCount,
);
const activeCheckpointDiff = useCheckpointDiff(
{
Expand All @@ -254,7 +294,7 @@ export default function DiffPanel({
fromTurnCount: selectedCheckpointRange?.fromTurnCount ?? null,
toTurnCount: selectedCheckpointRange?.toTurnCount ?? null,
ignoreWhitespace: diffIgnoreWhitespace,
cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : null,
cacheScope: selectedTurn ? reviewSectionId : null,
},
{ enabled: isGitRepo && selectedTurn !== undefined },
);
Expand Down Expand Up @@ -532,7 +572,11 @@ export default function DiffPanel({

const selectTurn = (turnId: TurnId) => {
if (!routeThreadRef) return;
useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId);
useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId, undefined, { baselineTurnId });
};
const pinBaseline = (baseline: DiffBaseline | null) => {
if (routeThreadRef && latestTurn)
useDiffPanelStore.getState().pinBaseline(routeThreadRef, baseline, latestTurn.turnId);
};
const selectGitScope = (scope: "branch" | "unstaged") => {
if (!routeThreadRef) return;
Expand All @@ -549,9 +593,9 @@ export default function DiffPanel({
<DropdownMenu>
<DropdownMenuTrigger
className="inline-flex h-6 max-w-full items-center gap-1 rounded-md bg-accent px-2 text-xs font-medium text-accent-foreground outline-none transition-colors hover:bg-accent/80 focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Diff scope: ${selectedScopeLabel}`}
aria-label={`Diff scope: ${comparisonLabel}`}
>
<span className="truncate">{selectedScopeLabel}</span>
<span className="truncate">{comparisonLabel}</span>
<ChevronDownIcon className="size-3.5 shrink-0 opacity-70" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60">
Expand All @@ -577,12 +621,13 @@ export default function DiffPanel({
</DropdownMenuItem>
<DropdownMenuItem
className={
selectedTurnId !== null && selectedTurn?.turnId === latestTurn?.turnId
selectedTurnId !== null && isLatestTurnSelection
? "bg-foreground/[0.08]"
: undefined
}
onClick={() => {
if (latestTurn) selectTurn(latestTurn.turnId);
if (latestTurn && routeThreadRef)
useDiffPanelStore.getState().selectLatestTurn(routeThreadRef, latestTurn.turnId);
}}
>
<span>Latest turn</span>
Expand All @@ -598,12 +643,26 @@ export default function DiffPanel({
return (
<DropdownMenuItem
key={summary.turnId}
className={
summary.turnId === selectedTurn?.turnId ? "bg-foreground/[0.08]" : undefined
className={cn(
summary.turnId === selectedTurn?.turnId &&
!followsLatest &&
"bg-foreground/[0.08]",
summary.status === "ready" &&
summary.files.length === 0 &&
"text-muted-foreground",
)}
disabled={
baselineTurnCount !== undefined &&
summary.checkpointTurnCount < baselineTurnCount
}
onClick={() => selectTurn(summary.turnId)}
>
<span>Turn {turnCount}</span>
<span>
Turn {turnCount}
{summary.status === "ready" && summary.files.length === 0
? " · No changes"
: ""}
</span>
<span className="ml-auto text-xs tabular-nums text-muted-foreground">
{formatShortTimestamp(summary.completedAt, settings.timestampFormat)}
</span>
Expand All @@ -612,6 +671,35 @@ export default function DiffPanel({
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub onOpenChange={setBaselineMenuOpen}>
<DropdownMenuSubTrigger>Pin baseline</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-64">
<DropdownMenuItem
disabled={
initialCheckpointDiff.data === null || initialCheckpointDiff.error !== null
}
onClick={() => pinBaseline(THREAD_START_DIFF_BASELINE)}
>
Start of thread (Turn 0){baselineTurnCount === 0 ? " (pinned)" : ""}
{initialCheckpointDiff.error ? " (unavailable)" : ""}
</DropdownMenuItem>
{orderedTurnDiffSummaries.map((summary) => (
<DropdownMenuItem
key={summary.turnId}
disabled={summary.status !== "ready"}
onClick={() => pinBaseline(summary.turnId)}
>
Turn {summary.checkpointTurnCount}
{summary.turnId === baselineTurnId ? " (pinned)" : ""}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
{baselineTurnCount !== undefined && (
<DropdownMenuItem onClick={() => pinBaseline(null)}>
Unpin baseline (Turn {baselineTurnCount})
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{selectedTurnId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && (
Expand Down
96 changes: 95 additions & 1 deletion apps/web/src/diffPanelStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts";
import { beforeEach, describe, expect, it } from "vite-plus/test";

import { selectThreadDiffPanelSelection, useDiffPanelStore } from "./diffPanelStore";
import {
getCheckpointDiffRange,
THREAD_START_DIFF_BASELINE,
selectThreadDiffPanelSelection,
useDiffPanelStore,
} from "./diffPanelStore";

const THREAD_REF = scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-1"));

Expand Down Expand Up @@ -84,4 +89,93 @@ describe("diffPanelStore", () => {
revealRequestId: 1,
});
});
it.each([TurnId.make("turn-1"), THREAD_START_DIFF_BASELINE] as const)(
"keeps %s pinned while latest advances and a fixed endpoint stays put",
(baseline) => {
const first = TurnId.make("turn-1");
const second = TurnId.make("turn-2");
const third = TurnId.make("turn-3");
const store = useDiffPanelStore.getState();
store.pinBaseline(THREAD_REF, baseline, second);
store.reconcileTurnSelection(THREAD_REF, [third, second, first]);
expect(
selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
).toMatchObject({ turnId: third, baselineTurnId: baseline, followLatest: true });
store.selectTurn(THREAD_REF, second, undefined, { baselineTurnId: baseline });
store.reconcileTurnSelection(THREAD_REF, [third, second, first]);
expect(
selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
).toMatchObject({ turnId: second, baselineTurnId: baseline });
store.selectLatestTurn(THREAD_REF, third);
expect(
selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
).toMatchObject({ turnId: third, baselineTurnId: baseline, followLatest: true });
store.pinBaseline(THREAD_REF, null, third);
expect(
selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
).not.toHaveProperty("baselineTurnId");
},
);

it("drops a removed baseline and falls back when every checkpoint is removed", () => {
const baseline = TurnId.make("turn-1");
const latest = TurnId.make("turn-2");
const store = useDiffPanelStore.getState();
store.pinBaseline(THREAD_REF, baseline, latest);
store.reconcileTurnSelection(THREAD_REF, [latest]);
expect(
selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
).toMatchObject({ turnId: latest, baselineTurnId: undefined });
store.pinBaseline(THREAD_REF, THREAD_START_DIFF_BASELINE, latest);
store.reconcileTurnSelection(THREAD_REF, []);
expect(
selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
).toEqual({ kind: "branch", baseRef: null });
});

it("opens a timeline turn on its own and isolates pins by environment", () => {
const turn = TurnId.make("turn-1");
const store = useDiffPanelStore.getState();
store.pinBaseline(THREAD_REF, turn, turn);
const other = scopeThreadRef(EnvironmentId.make("environment-2"), THREAD_REF.threadId);
expect(selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, other)).toEqual(
{ kind: "branch", baseRef: null },
);
store.selectTurn(THREAD_REF, turn);
expect(
selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
).not.toHaveProperty("baselineTurnId");
});

it("preserves the existing unpinned latest-turn selection behavior", () => {
const first = TurnId.make("turn-1");
const second = TurnId.make("turn-2");
const store = useDiffPanelStore.getState();
store.selectLatestTurn(THREAD_REF, first);
store.reconcileTurnSelection(THREAD_REF, [second, first]);
store.reconcileTurnSelection(THREAD_REF, []);
expect(
selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, THREAD_REF),
).toMatchObject({ kind: "turn", turnId: first });
});
});

describe("getCheckpointDiffRange", () => {
it("compares a single turn to its preceding snapshot by default", () => {
expect(getCheckpointDiffRange(1)).toEqual({ fromTurnCount: 0, toTurnCount: 1 });
expect(getCheckpointDiffRange(5)).toEqual({ fromTurnCount: 4, toTurnCount: 5 });
});
it("excludes the pinned turn's changes and follows the supplied endpoint", () => {
expect(getCheckpointDiffRange(5, 2)).toEqual({ fromTurnCount: 2, toTurnCount: 5 });
expect(getCheckpointDiffRange(6, 2)).toEqual({ fromTurnCount: 2, toTurnCount: 6 });
expect(getCheckpointDiffRange(2, 2)).toEqual({ fromTurnCount: 2, toTurnCount: 2 });
});
it("includes turn one when pinned to the initial snapshot", () => {
expect(getCheckpointDiffRange(1, 0)).toEqual({ fromTurnCount: 0, toTurnCount: 1 });
expect(getCheckpointDiffRange(6, 0)).toEqual({ fromTurnCount: 0, toTurnCount: 6 });
});
it("does not request an unavailable or reversed range", () => {
expect(getCheckpointDiffRange(null, 2)).toBeNull();
expect(getCheckpointDiffRange(1, 2)).toBeNull();
});
});
Loading
Loading