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
160 changes: 157 additions & 3 deletions apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
/* @vitest-environment jsdom */

import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { useAppStore } from "../../state/appStore";
import { clearPrReadInFlightForTest } from "../../lib/prReadCache";
import { ChatGitToolbar } from "./ChatGitToolbar";

const originalAde = globalThis.window.ade;

function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}

function installAdeMocks() {
globalThis.window.ade = {
git: {
Expand All @@ -28,6 +38,7 @@ function installAdeMocks() {
prs: {
getForLane: vi.fn().mockResolvedValue(null),
onEvent: vi.fn().mockImplementation(() => () => undefined),
refresh: vi.fn().mockResolvedValue([]),
getChecks: vi.fn().mockResolvedValue([]),
openInGitHub: vi.fn().mockResolvedValue(undefined),
},
Expand Down Expand Up @@ -90,13 +101,16 @@ function LocationProbe() {
return <div data-testid="location">{`${location.pathname}${location.search}`}</div>;
}

function renderToolbar() {
function renderToolbar(props: {
onTogglePrPane?: () => void;
prPaneOpen?: boolean;
} = {}) {
return render(
<MemoryRouter initialEntries={["/work"]}>
<Routes>
<Route path="*" element={(
<>
<ChatGitToolbar laneId="lane-1" />
<ChatGitToolbar laneId="lane-1" {...props} />
<LocationProbe />
</>
)}
Expand All @@ -109,6 +123,7 @@ function renderToolbar() {
describe("ChatGitToolbar", () => {
beforeEach(() => {
vi.stubGlobal("PointerEvent", MouseEvent);
clearPrReadInFlightForTest();
installAdeMocks();
resetStore();
});
Expand Down Expand Up @@ -193,4 +208,143 @@ describe("ChatGitToolbar", () => {
});
expect(window.ade.prs.openInGitHub).not.toHaveBeenCalled();
});

it("target-refreshes the linked PR when the chat PR pane opens", async () => {
vi.mocked(window.ade.prs.getForLane).mockResolvedValue({
id: "pr-1",
laneId: "lane-1",
title: "Stale linked PR",
state: "open",
checksStatus: "pending",
githubPrNumber: 333,
githubUrl: "https://github.com/acme/ade/pull/333",
additions: 2,
deletions: 1,
updatedAt: null,
} as any);
vi.mocked(window.ade.prs.refresh).mockResolvedValue([{
id: "pr-1",
laneId: "lane-1",
title: "Merged linked PR",
state: "merged",
checksStatus: "pending",
githubPrNumber: 333,
githubUrl: "https://github.com/acme/ade/pull/333",
additions: 2,
deletions: 1,
updatedAt: "2026-06-29T14:00:00.000Z",
} as any]);

function Harness() {
const [open, setOpen] = React.useState(false);
return (
<ChatGitToolbar
laneId="lane-1"
prPaneOpen={open}
onTogglePrPane={() => setOpen((value) => !value)}
/>
);
}

render(
<MemoryRouter initialEntries={["/work"]}>
<Harness />
</MemoryRouter>,
);

const badge = await screen.findByText("PR #333");
expect(window.ade.prs.refresh).not.toHaveBeenCalled();

fireEvent.click(badge.closest("button")!);

await waitFor(() => {
expect(window.ade.prs.refresh).toHaveBeenCalledWith({ prIds: ["pr-1"] });
});
expect(await screen.findByText("MERGED #333")).toBeTruthy();
});

it("ignores stale toolbar live refresh results after switching lanes", async () => {
const laneOnePr = {
id: "pr-lane-1",
laneId: "lane-1",
title: "Lane one stale PR",
state: "open",
checksStatus: "pending",
githubPrNumber: 111,
githubUrl: "https://github.com/acme/ade/pull/111",
additions: 2,
deletions: 1,
updatedAt: null,
};
const laneOneFreshPr = {
...laneOnePr,
title: "Lane one refreshed PR",
state: "merged",
updatedAt: "2026-06-29T14:00:00.000Z",
};
const laneTwoPr = {
id: "pr-lane-2",
laneId: "lane-2",
title: "Lane two PR",
state: "open",
checksStatus: "passing",
githubPrNumber: 222,
githubUrl: "https://github.com/acme/ade/pull/222",
additions: 4,
deletions: 0,
updatedAt: null,
};
const laneOneLive = deferred<any[]>();

vi.mocked(window.ade.prs.getForLane).mockImplementation(async (requestedLaneId: string) => (
requestedLaneId === "lane-1" ? laneOnePr : laneTwoPr
) as any);
vi.mocked(window.ade.prs.refresh).mockImplementation((args?: { prIds?: string[] }) => (
args?.prIds?.[0] === "pr-lane-1" ? laneOneLive.promise : Promise.resolve([laneTwoPr])
) as any);

function Harness() {
const [laneId, setLaneId] = React.useState("lane-1");
const [open, setOpen] = React.useState(false);
return (
<>
<button type="button" onClick={() => {
setOpen(false);
setLaneId("lane-2");
}}>
Switch lane
</button>
<ChatGitToolbar
laneId={laneId}
prPaneOpen={open}
onTogglePrPane={() => setOpen((value) => !value)}
/>
</>
);
}

render(
<MemoryRouter initialEntries={["/work"]}>
<Harness />
</MemoryRouter>,
);

fireEvent.click((await screen.findByText("PR #111")).closest("button")!);

await waitFor(() => {
expect(window.ade.prs.refresh).toHaveBeenCalledWith({ prIds: ["pr-lane-1"] });
});

fireEvent.click(screen.getByRole("button", { name: "Switch lane" }));

expect(await screen.findByText("PR #222")).toBeTruthy();

await act(async () => {
laneOneLive.resolve([laneOneFreshPr]);
await laneOneLive.promise;
});

expect(screen.getByText("PR #222")).toBeTruthy();
expect(screen.queryByText("MERGED #111")).toBeNull();
});
});
40 changes: 34 additions & 6 deletions apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { DiffChanges, PrSummary, PrCheck } from "../../../shared/types";
import { useLaneGitActionRuntimeState } from "../lanes/LaneGitActionsPane";
import { formatPrBadgeLabel } from "../prs/shared/prFormatters";
import { useAppStore } from "../../state/appStore";
import { refreshLinkedPrCoalesced } from "../../lib/prReadCache";

// ---------------------------------------------------------------------------
// Types
Expand All @@ -41,7 +42,8 @@ function dirtyFileCount(changes: DiffChanges): number {
return changes.staged.length + changes.unstaged.length;
}

function checksIcon(status: PrSummary["checksStatus"]) {
function checksIcon(status: PrSummary["checksStatus"], state: PrSummary["state"]) {
if (state !== "open" && state !== "draft") return null;
switch (status) {
case "passing":
return <CheckCircle size={10} weight="fill" className="text-emerald-400/80" />;
Expand Down Expand Up @@ -114,6 +116,7 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({
const navigate = useNavigate();
const runtime = useLaneGitActionRuntimeState(laneId);
const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote");
const projectRoot = useAppStore((s) => s.project?.rootPath ?? s.projectBinding?.rootPath ?? null);

const [dirtyCount, setDirtyCount] = useState(0);
const [linkedPr, setLinkedPr] = useState<PrSummary | null>(null);
Expand All @@ -123,6 +126,9 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({
const [prChecks, setPrChecks] = useState<PrCheck[] | null>(null);
const [prChecksLoading, setPrChecksLoading] = useState(false);
const [copyConfirmed, setCopyConfirmed] = useState(false);
const laneIdRef = React.useRef(laneId);
const refreshPrRequestRef = React.useRef(0);
laneIdRef.current = laneId;

// -----------------------------------------------------------------------
// Refresh git status + PR link
Expand All @@ -137,18 +143,34 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({
}
}, [laneId]);

const refreshPr = useCallback(async () => {
const refreshPr = useCallback(async (options: { live?: boolean } = {}) => {
const requestId = refreshPrRequestRef.current + 1;
refreshPrRequestRef.current = requestId;
const requestIsCurrent = () => laneIdRef.current === laneId && refreshPrRequestRef.current === requestId;
try {
const pr = await window.ade.prs.getForLane(laneId);
if (!requestIsCurrent()) return null;
setLinkedPr(pr);
setPrLoaded(true);
if (options.live && pr) {
try {
const refreshed = await refreshLinkedPrCoalesced(pr, { projectRoot });
if (!requestIsCurrent()) return null;
setLinkedPr(refreshed);
return refreshed;
} catch {
return pr;
}
}
return pr;
} catch {
setLinkedPr(null);
setPrLoaded(true);
if (requestIsCurrent()) {
setLinkedPr(null);
setPrLoaded(true);
}
return null;
}
}, [laneId]);
}, [laneId, projectRoot]);

useEffect(() => {
setDirtyCount(0);
Expand Down Expand Up @@ -228,6 +250,12 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({
setPrChecks(null);
}, [linkedPrId]);

useEffect(() => {
if (!linkedPrId) return;
if (!prPaneOpen && !prMenuOpen) return;
void refreshPr({ live: true });
}, [linkedPrId, prMenuOpen, prPaneOpen, refreshPr]);

// Fetch live check details when the PR menu opens. Lazy: only fetched while
// the menu is open so closed-state idles cost zero.
useEffect(() => {
Expand Down Expand Up @@ -308,7 +336,7 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({
>
<span className={cn("inline-block h-1.5 w-1.5 rounded-full", prStateDot(linkedPr.state))} />
<span>{label}</span>
{checksIcon(linkedPr.checksStatus)}
{checksIcon(linkedPr.checksStatus, linkedPr.state)}
<CaretRight
size={9}
weight="bold"
Expand Down
Loading
Loading