diff --git a/src/components/GitHubSkillSyncConfiguration.tsx b/src/components/GitHubSkillSyncConfiguration.tsx
index 140c24f6..c8e88f8d 100644
--- a/src/components/GitHubSkillSyncConfiguration.tsx
+++ b/src/components/GitHubSkillSyncConfiguration.tsx
@@ -6,7 +6,7 @@ import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
-export type GitHubSkillSyncPublisherOption = {
+type GitHubSkillSyncPublisherOption = {
publisher: {
_id: Id<"publishers">;
handle: string;
@@ -27,7 +27,7 @@ export type GitHubSkillSyncRepository = {
unavailableReason: "disabled" | null;
};
-export type GitHubSkillSyncPreviewItem = {
+type GitHubSkillSyncPreviewItem = {
slug: string;
displayName: string;
path: string;
@@ -297,6 +297,7 @@ function classificationLabel(classification: GitHubSkillSyncPreviewItem["classif
case "ownership-conflict":
return "Ownership conflict";
}
+ return assertNever(classification);
}
function classificationTone(classification: GitHubSkillSyncPreviewItem["classification"]) {
@@ -309,6 +310,11 @@ function classificationTone(classification: GitHubSkillSyncPreviewItem["classifi
case "ownership-conflict":
return "text-status-error-fg";
}
+ return assertNever(classification);
+}
+
+function assertNever(value: never): never {
+ throw new Error(`Unsupported GitHub Skill Sync classification: ${String(value)}`);
}
function previewReasonLabel(reason: string) {
diff --git a/src/routes/-settings.test.tsx b/src/routes/-settings.test.tsx
index 82503c48..6f42feb7 100644
--- a/src/routes/-settings.test.tsx
+++ b/src/routes/-settings.test.tsx
@@ -1,5 +1,5 @@
/* @vitest-environment jsdom */
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { getFunctionName } from "convex/server";
import type { FunctionReturnType } from "convex/server";
import type { ReactNode } from "react";
@@ -801,6 +801,93 @@ describe("Settings", () => {
).toBe(true);
});
+ it("ignores a preview response after the selected repository changes", async () => {
+ const listRepositories = vi.fn().mockResolvedValue({
+ publisher: { _id: "publisher_patrick", handle: "patrick", kind: "user" },
+ page: 1,
+ perPage: 100,
+ hasMore: false,
+ repositories: [
+ {
+ repositoryId: "1",
+ repo: "patrick-erichsen/skills",
+ ownerId: "123",
+ ownerLogin: "patrick-erichsen",
+ defaultBranch: "main",
+ archived: false,
+ disabled: false,
+ fork: false,
+ pushedAt: "2026-07-23T12:00:00Z",
+ selectable: true,
+ unavailableReason: null,
+ },
+ ],
+ });
+ let resolvePreview: ((value: unknown) => void) | undefined;
+ const previewRepository = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolvePreview = resolve;
+ }),
+ );
+ useActionMock.mockImplementation((action) => {
+ const actionName = getFunctionName(action);
+ if (actionName === "githubSkillSyncSettings:listRepositories") return listRepositories;
+ if (actionName === "githubSkillSyncSettings:previewRepository") return previewRepository;
+ return vi.fn();
+ });
+ mockSignedInSettings({
+ search: { view: "githubSources" },
+ memberships: [personalMembership],
+ });
+
+ render();
+
+ await waitFor(() => expect(listRepositories).toHaveBeenCalled());
+ fireEvent.click(screen.getByRole("button", { name: "Preview repository" }));
+ await waitFor(() => expect(previewRepository).toHaveBeenCalled());
+
+ fireEvent.change(screen.getByLabelText("Repository URL"), {
+ target: { value: "patrick-erichsen/other-skills" },
+ });
+ await act(async () => {
+ resolvePreview?.({
+ publisher: { _id: "publisher_patrick", handle: "patrick", kind: "user" },
+ repository: {
+ requestedRepo: "patrick-erichsen/skills",
+ repositoryId: "1",
+ repo: "patrick-erichsen/skills",
+ redirected: false,
+ defaultBranch: "main",
+ commit: "a".repeat(40),
+ },
+ summary: {
+ total: 1,
+ newDestinations: 1,
+ replacements: 0,
+ unavailable: 0,
+ conflicts: 0,
+ },
+ items: [
+ {
+ slug: "html",
+ displayName: "HTML",
+ path: "skills/html",
+ contentHash: "hash-html",
+ classification: "new-destination",
+ eligible: true,
+ destination: null,
+ },
+ ],
+ });
+ });
+
+ expect(screen.getByLabelText("Repository URL").value).toBe(
+ "patrick-erichsen/other-skills",
+ );
+ expect(screen.queryByRole("heading", { name: "Repository preview" })).toBeNull();
+ });
+
it("shows synced repos as separate cards and lets owners delete a source", async () => {
const deleteSource = vi.fn().mockResolvedValue({ ok: true, deletedSkills: 0 });
useMutationMock.mockImplementation((mutation) =>
diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx
index d89ab78f..9e0a42f2 100644
--- a/src/routes/settings.tsx
+++ b/src/routes/settings.tsx
@@ -36,6 +36,7 @@ import {
type FormEvent,
type ReactNode,
useEffect,
+ useRef,
useState,
} from "react";
import { toast } from "sonner";
@@ -331,6 +332,7 @@ export function Settings() {
const [isLoadingGitHubRepositories, setIsLoadingGitHubRepositories] = useState(false);
const [githubSyncPreview, setGitHubSyncPreview] = useState(null);
const [isPreviewingGitHubSource, setIsPreviewingGitHubSource] = useState(false);
+ const githubSyncPreviewRequestId = useRef(0);
const [deletingSourceId, setDeletingSourceId] = useState | null>(null);
const [sourceToDelete, setSourceToDelete] = useState(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -472,7 +474,7 @@ export function Settings() {
) {
setGitHubRepositories([]);
setGitHubRepositoriesError(null);
- return;
+ return undefined;
}
let cancelled = false;
setIsLoadingGitHubRepositories(true);
@@ -788,6 +790,8 @@ export function Settings() {
if (!selectedSourcePublisher) return;
const repo = parseGitHubRepoInput(githubRepo);
if (!repo) return;
+ const requestId = githubSyncPreviewRequestId.current + 1;
+ githubSyncPreviewRequestId.current = requestId;
setIsPreviewingGitHubSource(true);
setGitHubSyncPreview(null);
try {
@@ -795,12 +799,16 @@ export function Settings() {
publisherId: selectedSourcePublisher.publisher._id,
repo,
});
+ if (githubSyncPreviewRequestId.current !== requestId) return;
setGithubRepo(result.repository.repo);
setGitHubSyncPreview(result as GitHubSkillSyncPreview);
} catch (error) {
+ if (githubSyncPreviewRequestId.current !== requestId) return;
toast.error(getUserFacingConvexError(error, "GitHub repository could not be previewed."));
} finally {
- setIsPreviewingGitHubSource(false);
+ if (githubSyncPreviewRequestId.current === requestId) {
+ setIsPreviewingGitHubSource(false);
+ }
}
}
@@ -1706,17 +1714,21 @@ export function Settings() {
publisherOptions={githubSourcePublishers}
selectedPublisherId={selectedSourcePublisher.publisher._id}
onPublisherChange={(publisherId) => {
+ githubSyncPreviewRequestId.current += 1;
setSelectedSourcePublisherId(publisherId);
setGithubRepo("");
setGitHubSyncPreview(null);
+ setIsPreviewingGitHubSource(false);
}}
repositories={githubRepositories}
repositoriesError={githubRepositoriesError}
isLoadingRepositories={isLoadingGitHubRepositories}
githubRepo={githubRepo}
onGithubRepoChange={(repo) => {
+ githubSyncPreviewRequestId.current += 1;
setGithubRepo(repo);
setGitHubSyncPreview(null);
+ setIsPreviewingGitHubSource(false);
}}
onPreview={onPreviewGitHubSource}
isPreviewing={isPreviewingGitHubSource}