Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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<GetExperimentCandidateStatsResult>
>();

function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}

const candidate: ExperimentCandidate = {
threadId: "thread-1",
Expand Down Expand Up @@ -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(
<ExperimentCandidateCard
Expand All @@ -58,10 +97,21 @@ function renderCard(props: { isCreatingPr: boolean; isMerging: boolean }) {
}

describe("ExperimentCandidateCard", () => {
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: {} });
});
});

Expand Down Expand Up @@ -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<GetExperimentCandidateStatsResult>();
const second = deferred<GetExperimentCandidateStatsResult>();
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();
});
});
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
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";
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<string, GetExperimentCandidateStatsResult | "unavailable">();
import { useExperimentCandidateStats } from "./useExperimentCandidateStats";

export function ExperimentCandidateCard(props: {
candidate: ExperimentCandidate;
Expand Down Expand Up @@ -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<CandidateStatsState>("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 : "";
Expand Down Expand Up @@ -146,6 +111,9 @@ export function ExperimentCandidateCard(props: {
}}
>
<span className="flex items-center gap-1 font-medium tabular-nums">
{statsRefreshing ? (
<Loader2 className="size-2.5 animate-spin text-muted" aria-hidden />
) : null}
<span className="text-success">+{stats.insertions}</span>
<span className="text-danger">−{stats.deletions}</span>
</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, GetExperimentCandidateStatsResult | "unavailable">();

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<CandidateStatsState>("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<ReturnType<typeof setTimeout> | 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();
}
2 changes: 1 addition & 1 deletion src/supervisor/git/experimentService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading