diff --git a/src/renderer/views/ExperimentView/parts/ExperimentCandidateCard.test.tsx b/src/renderer/views/ExperimentView/parts/ExperimentCandidateCard.test.tsx index 57d2182a5..da97d2bd3 100644 --- a/src/renderer/views/ExperimentView/parts/ExperimentCandidateCard.test.tsx +++ b/src/renderer/views/ExperimentView/parts/ExperimentCandidateCard.test.tsx @@ -1,10 +1,35 @@ -import { act, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ExperimentCandidate, Thread } from "@/shared/contracts"; +import { act, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + ExperimentCandidate, + GetExperimentCandidateStatsResult, + GitStatusResult, + ProjectLocation, + Thread, +} from "@/shared/contracts"; import { useAppStore } from "@/renderer/state/appStore"; import { useGitStore } from "@/renderer/state/gitStore"; import { renderWithI18n as render } from "@/renderer/testUtils/i18n"; import { ExperimentCandidateCard } from "./ExperimentCandidateCard"; +import { __resetExperimentCandidateStatsCacheForTest } from "./useExperimentCandidateStats"; + +const getExperimentCandidateStats = + vi.fn< + (payload: { + projectLocation: ProjectLocation; + baseRef: string; + }) => Promise + >(); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} const candidate: ExperimentCandidate = { threadId: "thread-1", @@ -35,6 +60,20 @@ const thread: Thread = { updatedAt: "2026-07-16T00:00:00.000Z", }; +const worktreeStatus: GitStatusResult = { + isRepo: true, + branch: candidate.worktreeBranch, + tracking: "", + hasRemote: false, + remoteInfo: null, + ahead: 0, + behind: 0, + staged: [], + unstaged: [], + totalInsertions: 0, + totalDeletions: 0, +}; + function renderCard(props: { isCreatingPr: boolean; isMerging: boolean }) { return render( { + beforeEach(() => { + getExperimentCandidateStats.mockReset(); + __resetExperimentCandidateStatsCacheForTest(); + Object.defineProperty(window, "poracode", { + configurable: true, + value: { + getExperimentCandidateStats, + }, + }); + }); + afterEach(() => { act(() => { - useAppStore.setState({ threads: [] }); - useGitStore.setState({ prData: {} }); + useAppStore.setState({ projects: [], threads: [] }); + useGitStore.setState({ prData: {}, worktreeStatuses: {} }); }); }); @@ -129,4 +179,90 @@ describe("ExperimentCandidateCard", () => { const prButton = screen.getByRole("button", { name: "Open PR #328" }); expect(prButton.querySelector(".lucide-git-pull-request")).toHaveClass("text-danger"); }); + + it("shows completed stats while a fresher worktree refresh is pending", async () => { + const first = deferred(); + const second = deferred(); + getExperimentCandidateStats + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + act(() => { + useAppStore.setState({ + projects: [ + { + id: thread.projectId, + name: "Candidate project", + location: { kind: "posix", path: "/repo" }, + createdAt: "2026-07-16T00:00:00.000Z", + }, + ], + threads: [thread], + }); + useGitStore.getState().setWorktreeStatus(thread.worktreePath!, worktreeStatus); + }); + + renderCard({ isCreatingPr: false, isMerging: false }); + await waitFor(() => expect(getExperimentCandidateStats).toHaveBeenCalledTimes(1)); + + act(() => { + useGitStore.getState().setWorktreeStatus(thread.worktreePath!, { + ...worktreeStatus, + unstaged: [ + { + path: "bench/new.ts", + status: "?", + staged: false, + insertions: 4_436, + deletions: 0, + }, + ], + totalInsertions: 4_436, + }); + }); + await waitFor(() => expect(getExperimentCandidateStats).toHaveBeenCalledTimes(2)); + + first.resolve({ insertions: 261, deletions: 0, files: 3 }); + await waitFor(() => expect(screen.getByText("+261")).toBeInTheDocument()); + expect( + screen + .getByRole("button", { name: "Review candidate 1 (GPT-5) changes" }) + .querySelector(".animate-spin"), + ).not.toBe(null); + + second.resolve({ insertions: 4_436, deletions: 0, files: 267 }); + await waitFor(() => expect(screen.getByText("+4436")).toBeInTheDocument()); + expect( + screen + .getByRole("button", { name: "Review candidate 1 (GPT-5) changes" }) + .querySelector(".animate-spin"), + ).toBe(null); + }); + + it("retries a failed stats refresh while the candidate is active", async () => { + getExperimentCandidateStats + .mockRejectedValueOnce(new Error("candidate changed")) + .mockResolvedValueOnce({ insertions: 12, deletions: 1, files: 2 }); + act(() => { + useAppStore.setState({ + projects: [ + { + id: thread.projectId, + name: "Candidate project", + location: { kind: "posix", path: "/repo" }, + createdAt: "2026-07-16T00:00:00.000Z", + }, + ], + threads: [{ ...thread, status: "working" }], + }); + useGitStore.getState().setWorktreeStatus(thread.worktreePath!, worktreeStatus); + }); + + renderCard({ isCreatingPr: false, isMerging: false }); + + await waitFor(() => expect(getExperimentCandidateStats).toHaveBeenCalledTimes(2), { + timeout: 2_000, + }); + expect(await screen.findByText("+12")).toBeInTheDocument(); + expect(screen.getByText("−1")).toBeInTheDocument(); + }); }); diff --git a/src/renderer/views/ExperimentView/parts/ExperimentCandidateCard.tsx b/src/renderer/views/ExperimentView/parts/ExperimentCandidateCard.tsx index 0da14eceb..347f23239 100644 --- a/src/renderer/views/ExperimentView/parts/ExperimentCandidateCard.tsx +++ b/src/renderer/views/ExperimentView/parts/ExperimentCandidateCard.tsx @@ -1,11 +1,8 @@ -import { useEffect, useState } from "react"; import { ButtonGroup, Dropdown, Label } from "@heroui/react"; import { Plural, Trans, useLingui } from "@lingui/react/macro"; import { ChevronDown, Crown, GitMerge, GitPullRequest, Loader2 } from "lucide-react"; -import type { ExperimentCandidate, GetExperimentCandidateStatsResult } from "@/shared/contracts"; -import { buildWorktreeLocation } from "@/shared/worktree"; +import type { ExperimentCandidate } from "@/shared/contracts"; import { showGitReviewPanel } from "@/renderer/actions/panelActions"; -import { readBridge } from "@/renderer/bridge"; import { Button } from "@/renderer/components/common/Button"; import { ThreadProviderIcon } from "@/renderer/components/providers/ThreadProviderIcon"; import { useAppStore } from "@/renderer/state/appStore"; @@ -13,10 +10,7 @@ import { useGitStore } from "@/renderer/state/gitStore"; import { useThreadHasLiveWorkflow } from "@/renderer/state/threadLiveWorkflowStore"; import { useThread } from "@/renderer/state/useThread"; import { getPrStatusTone, PR_TONE_TEXT_CLASS } from "@/renderer/utils/prStatus"; - -type CandidateStatsState = GetExperimentCandidateStatsResult | "loading" | "unavailable"; - -const candidateStatsCache = new Map(); +import { useExperimentCandidateStats } from "./useExperimentCandidateStats"; export function ExperimentCandidateCard(props: { candidate: ExperimentCandidate; @@ -57,43 +51,14 @@ export function ExperimentCandidateCard(props: { const hasPullRequest = prNumber !== undefined && pullRequest?.state !== "closed"; const prIconClass = PR_TONE_TEXT_CLASS[getPrStatusTone(pullRequest?.state, pullRequest?.checksStatus)]; - const statsCacheKey = worktreePath ? `${worktreePath}\0${props.baseCommit}` : ""; - const [stats, setStats] = useState("loading"); - useEffect(() => { - if (!projectLocation || !worktreePath) { - setStats(candidate.worktreeState === "pending" ? "loading" : "unavailable"); - return; - } - let cancelled = false; - const cached = candidateStatsCache.get(statsCacheKey); - if (cached) setStats(cached); - void readBridge() - .getExperimentCandidateStats({ - projectLocation: buildWorktreeLocation(projectLocation, worktreePath), - baseRef: props.baseCommit, - }) - .then((nextStats) => { - candidateStatsCache.set(statsCacheKey, nextStats); - if (!cancelled) setStats(nextStats); - }) - .catch(() => { - if (!candidateStatsCache.has(statsCacheKey)) { - candidateStatsCache.set(statsCacheKey, "unavailable"); - if (!cancelled) setStats("unavailable"); - } - }); - return () => { - cancelled = true; - }; - }, [ - candidate.worktreeState, - isActive, + const { stats, isRefreshing: statsRefreshing } = useExperimentCandidateStats({ projectLocation, - props.baseCommit, - statsCacheKey, worktreePath, + baseCommit: props.baseCommit, + worktreeState: candidate.worktreeState, worktreeStatus, - ]); + isActive, + }); const provider = candidate.agentLabel ?? candidate.agentKind; const label = props.configLabel || provider; const details = props.configLabel ? provider : ""; @@ -146,6 +111,9 @@ export function ExperimentCandidateCard(props: { }} > + {statsRefreshing ? ( + + ) : null} +{stats.insertions} −{stats.deletions} diff --git a/src/renderer/views/ExperimentView/parts/useExperimentCandidateStats.ts b/src/renderer/views/ExperimentView/parts/useExperimentCandidateStats.ts new file mode 100644 index 000000000..b8ea7e52c --- /dev/null +++ b/src/renderer/views/ExperimentView/parts/useExperimentCandidateStats.ts @@ -0,0 +1,115 @@ +import { useEffect, useRef, useState } from "react"; +import type { + ExperimentCandidate, + GetExperimentCandidateStatsResult, + GitStatusResult, + ProjectLocation, +} from "@/shared/contracts"; +import { buildWorktreeLocation } from "@/shared/worktree"; +import { readBridge } from "@/renderer/bridge"; + +export type CandidateStatsState = GetExperimentCandidateStatsResult | "loading" | "unavailable"; + +const ACTIVE_RETRY_DELAY_MS = 500; +const candidateStatsCache = new Map(); + +export function useExperimentCandidateStats(args: { + projectLocation: ProjectLocation | undefined; + worktreePath: string | undefined; + baseCommit: string; + worktreeState: ExperimentCandidate["worktreeState"]; + worktreeStatus: GitStatusResult | undefined; + isActive: boolean; +}): { stats: CandidateStatsState; isRefreshing: boolean } { + const statsCacheKey = args.worktreePath ? `${args.worktreePath}\0${args.baseCommit}` : ""; + const [stats, setStats] = useState("loading"); + const [isRefreshing, setIsRefreshing] = useState(false); + const [retryNonce, setRetryNonce] = useState(0); + const mountedRef = useRef(false); + const activeStatsCacheKeyRef = useRef(""); + const latestRequestedRef = useRef(0); + const latestAppliedRef = useRef(0); + const retryTimerRef = useRef | null>(null); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (retryTimerRef.current) clearTimeout(retryTimerRef.current); + }; + }, []); + + useEffect(() => { + activeStatsCacheKeyRef.current = statsCacheKey; + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + + if (!args.projectLocation || !args.worktreePath) { + latestRequestedRef.current += 1; + setStats(args.worktreeState === "pending" ? "loading" : "unavailable"); + setIsRefreshing(false); + return; + } + + const requestId = latestRequestedRef.current + 1; + latestRequestedRef.current = requestId; + const cached = candidateStatsCache.get(statsCacheKey); + if (cached) setStats(cached); + setIsRefreshing(true); + + void readBridge() + .getExperimentCandidateStats({ + projectLocation: buildWorktreeLocation(args.projectLocation, args.worktreePath), + baseRef: args.baseCommit, + }) + .then((nextStats) => { + if ( + !mountedRef.current || + activeStatsCacheKeyRef.current !== statsCacheKey || + requestId < latestAppliedRef.current + ) { + return; + } + latestAppliedRef.current = requestId; + candidateStatsCache.set(statsCacheKey, nextStats); + setStats(nextStats); + if (requestId === latestRequestedRef.current) setIsRefreshing(false); + }) + .catch(() => { + if ( + !mountedRef.current || + activeStatsCacheKeyRef.current !== statsCacheKey || + requestId !== latestRequestedRef.current + ) { + return; + } + if (args.isActive) { + retryTimerRef.current = setTimeout(() => { + retryTimerRef.current = null; + if (mountedRef.current) setRetryNonce((value) => value + 1); + }, ACTIVE_RETRY_DELAY_MS); + return; + } + candidateStatsCache.set(statsCacheKey, "unavailable"); + setStats("unavailable"); + setIsRefreshing(false); + }); + }, [ + args.baseCommit, + args.isActive, + args.projectLocation, + args.worktreePath, + args.worktreeState, + args.worktreeStatus, + retryNonce, + statsCacheKey, + ]); + + return { stats, isRefreshing }; +} + +export function __resetExperimentCandidateStatsCacheForTest(): void { + candidateStatsCache.clear(); +} diff --git a/src/supervisor/git/experimentService.test.ts b/src/supervisor/git/experimentService.test.ts index 5e07a8d37..6dcd0b962 100644 --- a/src/supervisor/git/experimentService.test.ts +++ b/src/supervisor/git/experimentService.test.ts @@ -61,7 +61,7 @@ describe("GitExperimentService", () => { const untrackedDiffCalls = execGitMock.mock.calls.filter(([, args]) => args.includes("--no-index"), ); - expect(untrackedDiffCalls).toHaveLength(2); + expect(untrackedDiffCalls).toHaveLength(1); expect(untrackedDiffCalls.every(([, args]) => args.at(-1) === ".venv/bin/python")).toBe(true); }); }); diff --git a/src/supervisor/git/experimentService.ts b/src/supervisor/git/experimentService.ts index 4ccf29a50..379cb00c9 100644 --- a/src/supervisor/git/experimentService.ts +++ b/src/supervisor/git/experimentService.ts @@ -41,20 +41,15 @@ export class GitExperimentService { throw new Error(msg("experiment.diff.baseFullCommit")); } - const first = await this.captureCandidateStats(location, baseRef); - const second = await this.captureCandidateStats(location, baseRef); - if ( - first.headCommit !== second.headCommit || - first.insertions !== second.insertions || - first.deletions !== second.deletions || - first.files !== second.files - ) { - throw new Error(msg("experiment.candidate.changedDuringStats")); - } + // These stats are advisory UI data, unlike the exact diff snapshot used for + // judging and merging. A second equality pass makes actively-writing + // candidates repeatedly reject and leaves their cards stuck on old cached + // values, while also doubling the cost for large untracked directories. + const snapshot = await this.captureCandidateStats(location, baseRef); return { - insertions: second.insertions, - deletions: second.deletions, - files: second.files, + insertions: snapshot.insertions, + deletions: snapshot.deletions, + files: snapshot.files, }; }