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
85 changes: 85 additions & 0 deletions scripts/screenshot/specs/github-action-error-toast.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Verifies that a failed gh action in the GitHub panel's PR detail pane
* (here, Close PR) reports the gh error as a toast instead of silently
* resetting the button.
*/

import userEvent from "@testing-library/user-event";
import { expect, it, vi } from "vitest";
import { GitHubPanel } from "../../../src/components/GitHubPanel";
import type { GhPullRequest } from "../../../src/lib/api-types";
import { render, screen } from "../../../test/test-utils";
import { createTestRepo } from "../../../test/utils";
import { captureDocument } from "../capture";

const api = vi.hoisted(() => ({
getGitRemoteUrl: vi.fn(),
ghListPrs: vi.fn(),
ghViewPr: vi.fn(),
ghClosePr: vi.fn(),
getPrChecksForPr: vi.fn(),
}));

vi.mock("../../../src/lib/api", async () => {
const actual = await vi.importActual<typeof import("../../../src/lib/api")>(
"../../../src/lib/api",
);
return {
...actual,
getGitRemoteUrl: api.getGitRemoteUrl,
ghListIssues: vi.fn().mockResolvedValue({ items: [], hasMore: false }),
ghListPrs: api.ghListPrs,
ghViewPr: api.ghViewPr,
ghClosePr: api.ghClosePr,
getPrChecksForPr: api.getPrChecksForPr,
};
});

const PR: GhPullRequest = {
number: 42,
title: "Close me",
state: "OPEN",
url: "https://github.com/acme/treq/pull/42",
body: null,
author: { login: "octocat", avatar_url: null },
labels: [],
head_ref_name: "feat/pr-42",
base_ref_name: "main",
merge_state_status: "CLEAN",
created_at: "2026-07-20T10:00:00Z",
updated_at: "2026-07-20T10:00:00Z",
comments: [],
is_draft: false,
};

it("shows an error toast when Close PR fails", async () => {
const { repoPath } = createTestRepo(false);
api.getGitRemoteUrl.mockResolvedValue({
owner: "acme",
repo: "treq",
full_name: "acme/treq",
});
api.getPrChecksForPr.mockResolvedValue(null);
api.ghListPrs.mockResolvedValue({ items: [PR], hasMore: false });
api.ghViewPr.mockResolvedValue(PR);
api.ghClosePr.mockRejectedValue(
"gh: GraphQL: Resource not accessible by integration (closePullRequest)",
);

const user = userEvent.setup();
render(<GitHubPanel repoPath={repoPath} onOpenSettings={vi.fn()} />);
await user.click(await screen.findByRole("tab", { name: /pull requests/i }));
await user.click(await screen.findByRole("button", { name: /close me/i }));
await user.click(await screen.findByRole("button", { name: /close pr/i }));

expect(await screen.findByText("Failed to close pull request")).toBeVisible();
expect(screen.getByRole("button", { name: /close pr/i })).toBeEnabled();

await captureDocument(document, {
name: "github-action-error-toast-01-close-failed",
expectations: [
'An error toast with a red alert icon reads "Failed to close pull request" with the gh "Resource not accessible by integration" message beneath it.',
"The PR detail still shows the PR as Open with an enabled Close PR button.",
],
});
}, 60000);
9 changes: 8 additions & 1 deletion src/components/changes-diff-viewer/hooks/useReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,14 @@ export function useReview({

const handleMarkFileViewed = async (filePath: string) => {
const fileData = allFileHunks.get(filePath);
const contentHash = fileData?.hunks ? computeHunksHash(fileData.hunks) : "";
// A diff still loading holds placeholder hunks. Hashing those would
// record a hash the real diff never matches, so the stale-content check
// above would un-mark the file as soon as it loads. An empty hash is
// never treated as stale.
const contentHash =
fileData && !fileData.isLoading && fileData.hunks.length > 0
? computeHunksHash(fileData.hunks)
: "";
const now = new Date().toISOString();
setViewedFiles((prev) =>
new Map(prev).set(filePath, { contentHash, viewedAt: now }),
Expand Down
102 changes: 102 additions & 0 deletions src/components/changes-diff-viewer/hooks/useReview.viewed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ParsedFileChange } from "../../../lib/git-utils";
import type { FileHunksData } from "../types";
import { useReview } from "./useReview";

vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../lib/api")>();
return {
...actual,
loadPendingReview: vi.fn().mockResolvedValue(null),
savePendingReview: vi.fn().mockResolvedValue(undefined),
clearPendingReview: vi.fn().mockResolvedValue(undefined),
markFileViewed: vi.fn().mockResolvedValue(undefined),
unmarkFileViewed: vi.fn().mockResolvedValue(undefined),
};
});

const FILE = "reviews-flow.txt";

const LOADED: FileHunksData = {
filePath: FILE,
isLoading: false,
hunks: [
{
id: "h1",
header: "@@ -0,0 +1,2 @@",
lines: ["+first review line\n", "+second review line\n"],
patch: "",
},
],
};

function renderUseReview(allFileHunks: Map<string, FileHunksData>) {
const files = [{ path: FILE }] as unknown as ParsedFileChange[];
return renderHook(
({ hunks }: { hunks: Map<string, FileHunksData> }) =>
useReview({
comments: [],
setComments: vi.fn(),
conflictComments: new Map(),
setConflictComments: vi.fn(),
setHasUserAddedComments: vi.fn(),
allFileHunks: hunks,
setAllFileHunks: vi.fn(),
files,
workspacePath: "/tmp/ws",
repoPath: undefined,
workspaceId: undefined,
conflictRegionsByFile: new Map(),
actualConflictedFiles: [],
setCollapsedFiles: vi.fn(),
applyChangedFilesRef: { current: vi.fn() },
isReloadingRef: { current: false },
onCreateAgentWithReview: undefined,
onReviewSubmitted: undefined,
addToast: vi.fn(),
}),
{ initialProps: { hunks: allFileHunks } },
);
}

describe("useReview viewed files", () => {
it("keeps a file viewed when it was marked before its diff finished loading", async () => {
const loading = new Map<string, FileHunksData>([
[FILE, { filePath: FILE, hunks: [], isLoading: true }],
]);
const { result, rerender } = renderUseReview(loading);

await act(async () => {
await result.current.handleMarkFileViewed(FILE);
});
expect(result.current.viewedFiles.has(FILE)).toBe(true);

rerender({ hunks: new Map([[FILE, LOADED]]) });

expect(result.current.viewedFiles.has(FILE)).toBe(true);
});

it("still un-marks a viewed file whose loaded diff changes", async () => {
const { result, rerender } = renderUseReview(new Map([[FILE, LOADED]]));

await act(async () => {
await result.current.handleMarkFileViewed(FILE);
});
expect(result.current.viewedFiles.has(FILE)).toBe(true);

rerender({
hunks: new Map([
[
FILE,
{
...LOADED,
hunks: [{ ...LOADED.hunks[0], lines: ["+changed line\n"] }],
},
],
]),
});

expect(result.current.viewedFiles.has(FILE)).toBe(false);
});
});
5 changes: 5 additions & 0 deletions src/components/github-panel/IssueDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
LabelChip,
OpenInWebButton,
StateChip,
useGhErrorToast,
} from "./shared";

export function IssueDetailPanel({
Expand All @@ -34,6 +35,7 @@ export function IssueDetailPanel({
onClose: () => void;
onStartPrompt?: (issue: GitHubIssueAttachment) => void;
}) {
const ghErrorToast = useGhErrorToast();
const [commentBody, setCommentBody] = useState("");

const {
Expand All @@ -51,6 +53,7 @@ export function IssueDetailPanel({
setCommentBody("");
void invalidateQueries(["gh-issue", repoFullName, issueNumber]);
},
onError: ghErrorToast("Failed to post comment"),
});

const closeIssue = useMutation({
Expand All @@ -59,6 +62,7 @@ export function IssueDetailPanel({
void invalidateQueries(["gh-issue", repoFullName, issueNumber]);
void invalidateQueries(["gh-issues", repoFullName]);
},
onError: ghErrorToast("Failed to close issue"),
});

const reopenIssue = useMutation({
Expand All @@ -67,6 +71,7 @@ export function IssueDetailPanel({
void invalidateQueries(["gh-issue", repoFullName, issueNumber]);
void invalidateQueries(["gh-issues", repoFullName]);
},
onError: ghErrorToast("Failed to reopen issue"),
});

return (
Expand Down
11 changes: 11 additions & 0 deletions src/components/github-panel/PrDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
LabelChip,
OpenInWebButton,
StateChip,
useGhErrorToast,
} from "./shared";

/** Branch glyph (Lucide GitBranch, upright — not the sidebar's Y-flipped form). */
Expand Down Expand Up @@ -71,6 +72,7 @@ export function PrDetailPanel({
onOpenWorkspace?: (workspaceId: number) => void;
}) {
const { addToast } = useToast();
const ghErrorToast = useGhErrorToast();
const [commentBody, setCommentBody] = useState("");

const {
Expand Down Expand Up @@ -129,6 +131,7 @@ export function PrDetailPanel({
setCommentBody("");
void invalidateQueries(["gh-pr", repoFullName, prNumber]);
},
onError: ghErrorToast("Failed to post comment"),
});

const closePr = useMutation({
Expand All @@ -137,6 +140,7 @@ export function PrDetailPanel({
void invalidateQueries(["gh-pr", repoFullName, prNumber]);
void invalidateQueries(["gh-prs", repoFullName]);
},
onError: ghErrorToast("Failed to close pull request"),
});

const reopenPr = useMutation({
Expand All @@ -145,6 +149,7 @@ export function PrDetailPanel({
void invalidateQueries(["gh-pr", repoFullName, prNumber]);
void invalidateQueries(["gh-prs", repoFullName]);
},
onError: ghErrorToast("Failed to reopen pull request"),
});

const setDraft = useMutation({
Expand All @@ -154,6 +159,12 @@ export function PrDetailPanel({
void invalidateQueries(["gh-prs", repoFullName]);
void invalidateQueries(["pr-info-gh"]);
},
onError: (error, draft) =>
ghErrorToast(
draft
? "Failed to convert to draft"
: "Failed to mark ready for review",
)(error),
});

return (
Expand Down
12 changes: 12 additions & 0 deletions src/components/github-panel/shared.tsx

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

useGhErrorToast lives here because both detail panes need it.

  • It uses a toast rather than inline text, which matches the existing openOrCreateWorkspace error path in PrDetail.
  • String(error) covers Tauri invoke rejections, which arrive as plain strings rather than Error objects.

Generated by Claude Code

Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
formatCheckDuration,
} from "../../lib/ci-status";
import { Button } from "../ui/button";
import { useToast } from "../ui/toast";

export function formatDate(iso: string) {
try {
Expand Down Expand Up @@ -281,3 +282,14 @@ export function ErrorState({
}

export { CircleDot, GitPullRequest };

/** Returns an `onError` factory that reports a failed gh action as an error toast. */
export function useGhErrorToast() {
const { addToast } = useToast();
return (title: string) => (error: unknown) =>
addToast({
title,
description: error instanceof Error ? error.message : String(error),
type: "error",
});
}
4 changes: 3 additions & 1 deletion src/hooks/useMutation.ts

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

mutate is the fire-and-forget form, so it now catches the error instead of letting it escape as an unhandled rejection.

  • The error still reaches error, isError and onError, so no error UI changes.
  • mutateAsync still throws, so callers that await it keep their try/catch behavior.

Generated by Claude Code

Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,10 @@ export function useMutation<TData, TVariables = void>(options: {
}
};

// Fire-and-forget: the error already lands in `error` and `onError`, so
// swallowing the rejection here keeps it from surfacing as unhandled.
const mutate = (variables: TVariables) => {
void mutateAsync(variables);
mutateAsync(variables).catch(() => {});
};

return {
Expand Down
Loading
Loading