diff --git a/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx b/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx
index 4f48615d63..01f8f856f6 100644
--- a/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx
+++ b/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx
@@ -20,6 +20,7 @@ import {
import { ActivationPreview } from "@/components/site/app-panels/activation-preview";
import { AiReviewSettings } from "@/components/site/app-panels/ai-review-settings";
import { MaintainerSettings } from "@/components/site/app-panels/maintainer-settings";
+import { OnboardingPreviewCard } from "@/components/site/app-panels/onboarding-preview-card";
import { CheckRunReadinessTable } from "@/components/site/check-run-readiness-table";
import type { CheckRunReadinessTableData } from "@/components/site/check-run-readiness-model";
import { StatCard } from "@/components/site/primitives";
@@ -119,7 +120,7 @@ type InstallPreview = {
}>;
};
-type SettingsPreviewResponse = {
+export type SettingsPreviewResponse = {
repoFullName: string;
generatedAt: string;
installation: {
@@ -224,6 +225,9 @@ function MaintainerDashboardView({
+
+
+
{data.metrics.map((metric) => (
({ apiFetch: vi.fn() }));
+vi.mock("@/lib/api/request", () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) }));
+vi.mock("@/lib/api/origin", () => ({ getApiOrigin: () => "https://api.test" }));
+
+import { OnboardingPreviewCard } from "@/components/site/app-panels/onboarding-preview-card";
+import type { SettingsPreviewResponse } from "@/components/site/app-panels/maintainer-panel";
+
+const INSTALL_PREVIEW = {
+ status: "ready" as const,
+ summary: "All checks pass.",
+ readScope: [],
+ computedContext: [],
+ previewBehavior: [],
+ permissions: {
+ status: "ready" as const,
+ required: [],
+ missing: [],
+ missingEvents: [],
+ summary: "ok",
+ },
+ publicOutputs: [],
+ privateOnlyContext: [],
+ commandAuthorization: [],
+ auditBehavior: [],
+ sanitizerBoundaries: [],
+ manualControls: [],
+ checklist: [],
+};
+
+function preview(overrides: Partial = {}): SettingsPreviewResponse {
+ return {
+ repoFullName: "acme/widgets",
+ generatedAt: "2026-07-10T00:00:00.000Z",
+ installation: null,
+ sample: {
+ authorLogin: "octocat",
+ authorType: "User",
+ authorAssociation: "NONE",
+ minerStatus: "confirmed",
+ title: "Add cursor pagination",
+ labels: [],
+ linkedIssues: [],
+ },
+ decision: {
+ willComment: true,
+ willLabel: true,
+ willCheckRun: true,
+ skipped: false,
+ skipReason: null,
+ actions: ["comment", "label", "check_run"],
+ summary: "Would comment and label this PR.",
+ },
+ previewComment: "Thanks for the PR! A couple of notes...",
+ appliedLabel: "gittensor:reviewed",
+ checkRun: { willCreate: true, title: "Gittensory review", detailLevel: "full" },
+ checkRunReadiness: null,
+ installPreview: INSTALL_PREVIEW,
+ warnings: [],
+ summary: "Would comment and label this PR.",
+ ...overrides,
+ };
+}
+
+const REVIEWABILITY = [
+ { pr: "acme/widgets#12", title: "Add cursor pagination", reason: "linked issue #7" },
+];
+
+describe("OnboardingPreviewCard", () => {
+ beforeEach(() => {
+ apiFetch.mockReset();
+ window.localStorage.clear();
+ });
+
+ it("auto-runs the demo against the most recent PR and shows flagged findings (comment+label+check-run)", async () => {
+ apiFetch.mockResolvedValue({ ok: true, data: preview() });
+ render();
+
+ await waitFor(() => expect(screen.getByText("Would comment and label this PR.")).toBeTruthy());
+ expect(screen.getByText("acme/widgets#12")).toBeTruthy();
+ expect(screen.getByText("comment")).toBeTruthy();
+ expect(screen.getByText("gittensor:reviewed")).toBeTruthy();
+
+ // Real title + real linked-issue number scraped from `reason` reach the request.
+ const [url, options] = apiFetch.mock.calls[0] as [string, { body: string; method: string }];
+ expect(url).toContain("/v1/repos/acme/widgets/settings-preview");
+ expect(options.method).toBe("POST");
+ const body = JSON.parse(options.body) as { sample: { title: string; linkedIssues: number[] } };
+ expect(body.sample.title).toBe("Add cursor pagination");
+ expect(body.sample.linkedIssues).toEqual([7]);
+ });
+
+ it("shows a clean/skip verdict for a PR the policy would not act on", async () => {
+ apiFetch.mockResolvedValue({
+ ok: true,
+ data: preview({
+ decision: {
+ willComment: false,
+ willLabel: false,
+ willCheckRun: false,
+ skipped: true,
+ skipReason: "author is a trusted maintainer",
+ actions: ["skip"],
+ summary: "Nothing to flag on this PR.",
+ },
+ summary: "Nothing to flag on this PR.",
+ }),
+ });
+ render();
+ await waitFor(() => expect(screen.getByText("Nothing to flag on this PR.")).toBeTruthy());
+ expect(screen.getByText("author is a trusted maintainer")).toBeTruthy();
+ });
+
+ it("renders an EmptyState when the repo has no recent pull requests, without calling the API", async () => {
+ render();
+ await waitFor(() => expect(screen.getByText(/No recent pull requests yet/i)).toBeTruthy());
+ expect(apiFetch).not.toHaveBeenCalled();
+ });
+
+ it("renders an error state when the demo run fails", async () => {
+ apiFetch.mockResolvedValue({ ok: false, message: "503 Service Unavailable" });
+ render();
+ await waitFor(() =>
+ expect(screen.getByText(/Couldn't run the onboarding preview/i)).toBeTruthy(),
+ );
+ expect(screen.getByText("503 Service Unavailable")).toBeTruthy();
+ });
+
+ it("dismisses the card and keeps it hidden (and skips the API call) across a remount", async () => {
+ apiFetch.mockResolvedValue({ ok: true, data: preview() });
+ const { unmount } = render();
+ await waitFor(() => expect(screen.getByText("Would comment and label this PR.")).toBeTruthy());
+
+ fireEvent.click(screen.getByRole("button", { name: "Dismiss onboarding preview" }));
+ expect(screen.queryByText(/Here's what Gittensory would have flagged/)).toBeNull();
+
+ apiFetch.mockClear();
+ unmount();
+ // render() flushes the synchronous hydration + load effects within its own act() wrapping (same
+ // assumption ActivationPreview's tests already rely on for its own initial-load effect), so both of
+ // these are safe to assert immediately rather than under waitFor.
+ render();
+ expect(screen.queryByText(/Here's what Gittensory would have flagged/)).toBeNull();
+ expect(apiFetch).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/gittensory-ui/src/components/site/app-panels/onboarding-preview-card.tsx b/apps/gittensory-ui/src/components/site/app-panels/onboarding-preview-card.tsx
new file mode 100644
index 0000000000..6c2df935a2
--- /dev/null
+++ b/apps/gittensory-ui/src/components/site/app-panels/onboarding-preview-card.tsx
@@ -0,0 +1,149 @@
+import { useCallback, useEffect, useState } from "react";
+import { X } from "lucide-react";
+
+import { StateBoundary } from "@/components/site/state-views";
+import {
+ PreviewResult,
+ type SettingsPreviewResponse,
+} from "@/components/site/app-panels/maintainer-panel";
+import { apiFetch } from "@/lib/api/request";
+import { getApiOrigin } from "@/lib/api/origin";
+import {
+ buildSettingsPreviewRequest,
+ splitRepoFullName,
+ type PreviewFormState,
+} from "@/lib/maintainer-settings-preview";
+import { useLocalStorage } from "@/lib/use-local-storage";
+
+type ReviewabilityRow = { pr: string; title: string; reason: string };
+
+const DISMISS_KEY = "gittensory_maintainer_onboarding_preview_dismissed";
+
+/** Builds a settings-preview form from a REAL cached PR (title, and a linked-issue number scraped from
+ * `reason` when present) — everything else (author identity, labels, body) isn't in the reviewability
+ * projection (see src/api/routes.ts's `reviewability` mapper), so it's filled from a representative
+ * scenario, same as SurfacePreview's own default. Returns null if the PR string doesn't parse. */
+function reviewabilityRowToForm(row: ReviewabilityRow): PreviewFormState | null {
+ const repoFullName = row.pr.split("#")[0] ?? "";
+ if (!splitRepoFullName(repoFullName)) return null;
+ const linkedIssueMatch = row.reason.match(/linked issue #(\d+)/);
+ return {
+ repoFullName,
+ scenarioId: "confirmed-miner",
+ title: row.title,
+ labels: "",
+ linkedIssues: linkedIssueMatch ? linkedIssueMatch[1] : "",
+ body: "",
+ };
+}
+
+/**
+ * First-session onboarding preview card (#2217, part of #701): auto-runs the same settings-preview
+ * simulator SurfacePreview drives manually, against this repo's most recently cached pull request, so a
+ * maintainer sees "here's what Gittensory would have flagged" without filling out a form. Dismissible via
+ * localStorage, matching this codebase's established first-visit-card idiom (app.index.tsx's
+ * OnboardingChecklist). Renders through PreviewResult — the same decision/checklist/comment-preview UI
+ * SurfacePreview already uses — rather than a new findings UI: the settings-preview response has no
+ * discrete findings array, so "flagged" here means decision.willComment / willLabel / willCheckRun.
+ */
+export function OnboardingPreviewCard({ reviewability }: { reviewability: ReviewabilityRow[] }) {
+ const [state, setState, hydrated] = useLocalStorage<{ dismissed: boolean }>(DISMISS_KEY, {
+ dismissed: false,
+ });
+ const target = reviewability[0] ?? null;
+ const [preview, setPreview] = useState(null);
+ const [loading, setLoading] = useState(Boolean(target));
+ const [error, setError] = useState(null);
+
+ const load = useCallback(async () => {
+ if (!target) {
+ setPreview(null);
+ setError(null);
+ return;
+ }
+ const form = reviewabilityRowToForm(target);
+ const repoParts = form ? splitRepoFullName(form.repoFullName) : null;
+ if (!form || !repoParts) {
+ setPreview(null);
+ setError(`Couldn't parse a repository from ${target.pr}.`);
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ const result = await apiFetch(
+ `${getApiOrigin().replace(/\/$/, "")}/v1/repos/${encodeURIComponent(repoParts.owner)}/${encodeURIComponent(repoParts.repo)}/settings-preview`,
+ {
+ method: "POST",
+ label: "Onboarding preview",
+ credentials: "include",
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
+ body: JSON.stringify(buildSettingsPreviewRequest(form)),
+ },
+ );
+ setLoading(false);
+ if (result.ok) {
+ setPreview(result.data);
+ } else {
+ setPreview(null);
+ setError(result.message);
+ }
+ }, [target]);
+
+ useEffect(() => {
+ if (!hydrated || state.dismissed) return;
+ void load();
+ }, [load, hydrated, state.dismissed]);
+
+ if (!hydrated || state.dismissed) return null;
+
+ return (
+
+
+
+
+ Here's what Gittensory would have flagged
+
+
+ {target ? (
+ <>
+ A live demo run of the review policy against{" "}
+ {target.pr}, your most recently cached pull
+ request.
+ >
+ ) : (
+ "A live demo run against this repo's most recent pull request."
+ )}
+