feat(renderer): MigrationPopup (Proceed / Skip / Don't Migrate)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
8b11e4a3ab
commit
1fdd47a676
|
|
@ -0,0 +1,88 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MigrationPopup } from "./MigrationPopup";
|
||||
|
||||
const { getMock, postMock, getMigration, setMigration } = vi.hoisted(() => ({
|
||||
getMock: vi.fn(),
|
||||
postMock: vi.fn(),
|
||||
getMigration: vi.fn(),
|
||||
setMigration: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/api-client", () => ({
|
||||
apiClient: { GET: getMock, POST: postMock },
|
||||
apiErrorMessage: (e: unknown, fb = "Request failed") => (e instanceof Error ? e.message : (e as { message?: string })?.message ?? fb),
|
||||
}));
|
||||
vi.mock("../lib/bridge", () => ({ aoBridge: { appState: { getMigration, setMigration } } }));
|
||||
|
||||
function renderPopup() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MigrationPopup />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return qc;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getMock.mockReset();
|
||||
postMock.mockReset();
|
||||
getMigration.mockReset();
|
||||
setMigration.mockReset();
|
||||
getMigration.mockResolvedValue({ status: "pending" });
|
||||
getMock.mockResolvedValue({ data: { available: true, legacyRoot: "/home/u/.agent-orchestrator" }, error: undefined });
|
||||
postMock.mockResolvedValue({ data: { report: { projectsImported: 2, projectsSkipped: 1 } }, error: undefined });
|
||||
setMigration.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("MigrationPopup", () => {
|
||||
it("shows when a legacy install is available and the marker is pending", async () => {
|
||||
renderPopup();
|
||||
expect(await screen.findByText(/Import projects from your earlier AO/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("/home/u/.agent-orchestrator")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when the marker is declined", async () => {
|
||||
getMigration.mockResolvedValue({ status: "declined" });
|
||||
renderPopup();
|
||||
await waitFor(() => expect(getMigration).toHaveBeenCalled());
|
||||
expect(screen.queryByText(/Import projects from your earlier AO/i)).not.toBeInTheDocument();
|
||||
expect(getMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Proceed imports, marks completed, and retires", async () => {
|
||||
renderPopup();
|
||||
await screen.findByText(/Import projects from your earlier AO/i);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Proceed" }));
|
||||
await waitFor(() => expect(postMock).toHaveBeenCalledWith("/api/v1/import"));
|
||||
expect(setMigration).toHaveBeenCalledWith(expect.objectContaining({ status: "completed" }));
|
||||
await waitFor(() => expect(screen.queryByText(/Import projects from your earlier AO/i)).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("Don't Migrate records declined", async () => {
|
||||
renderPopup();
|
||||
await screen.findByText(/Import projects from your earlier AO/i);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Don't Migrate" }));
|
||||
expect(setMigration).toHaveBeenCalledWith(expect.objectContaining({ status: "declined" }));
|
||||
});
|
||||
|
||||
it("Skip dismisses without writing the marker", async () => {
|
||||
renderPopup();
|
||||
await screen.findByText(/Import projects from your earlier AO/i);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Skip" }));
|
||||
expect(setMigration).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText(/Import projects from your earlier AO/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a failed import shows the lossless reassurance and marks failed", async () => {
|
||||
postMock.mockResolvedValue({ data: undefined, error: { message: "disk full" } });
|
||||
renderPopup();
|
||||
await screen.findByText(/Import projects from your earlier AO/i);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Proceed" }));
|
||||
expect(await screen.findByText(/nothing is ever deleted/i)).toBeInTheDocument();
|
||||
expect(setMigration).toHaveBeenCalledWith(expect.objectContaining({ status: "failed", error: "disk full" }));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "./ui/button";
|
||||
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
||||
import { aoBridge } from "../lib/bridge";
|
||||
import { migrationOfferQueryKey, useMigrationOffer } from "../hooks/useMigrationOffer";
|
||||
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
|
||||
|
||||
// MigrationPopup is the first-run legacy-AO import offer. It shows only when the
|
||||
// app marker is non-terminal (pending/failed) AND the daemon reports legacy data
|
||||
// available. Proceed runs the idempotent import through the daemon; Skip dismisses
|
||||
// for this launch (re-prompts next launch); Don't Migrate declines permanently
|
||||
// (re-runnable later once the Settings entry point lands, issue #2205).
|
||||
export function MigrationPopup() {
|
||||
const offer = useMigrationOffer();
|
||||
const queryClient = useQueryClient();
|
||||
const [skipped, setSkipped] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
const open = (offer.data?.show ?? false) && !skipped;
|
||||
if (!open) return null;
|
||||
|
||||
const legacyRoot = offer.data?.legacyRoot || "your earlier AO";
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
const proceed = async () => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
const { data, error: apiErr } = await apiClient.POST("/api/v1/import");
|
||||
if (apiErr) {
|
||||
const msg = apiErrorMessage(apiErr);
|
||||
setError(msg);
|
||||
await aoBridge.appState.setMigration({ status: "failed", lastAttemptAt: nowIso(), error: msg });
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
const report = data?.report;
|
||||
await aoBridge.appState.setMigration({
|
||||
status: "completed",
|
||||
lastAttemptAt: nowIso(),
|
||||
completedAt: nowIso(),
|
||||
report: report
|
||||
? { projectsImported: report.projectsImported, projectsSkipped: report.projectsSkipped }
|
||||
: undefined,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
||||
await queryClient.invalidateQueries({ queryKey: migrationOfferQueryKey });
|
||||
setSkipped(true);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const dontMigrate = async () => {
|
||||
await aoBridge.appState.setMigration({ status: "declined", lastAttemptAt: nowIso() });
|
||||
await queryClient.invalidateQueries({ queryKey: migrationOfferQueryKey });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root
|
||||
open
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setSkipped(true);
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
|
||||
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[440px] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-surface p-5 shadow-lg">
|
||||
<Dialog.Title className="text-sm font-medium text-foreground">
|
||||
Import projects from your earlier AO?
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="mt-2 text-[13px] leading-[1.5] text-muted-foreground">
|
||||
We found an existing install at{" "}
|
||||
<span className="font-mono text-[11px] text-foreground">{legacyRoot}</span>. Importing brings in
|
||||
your projects. Your old files are never modified, and you can do this later.
|
||||
</Dialog.Description>
|
||||
{error && (
|
||||
<div className="mt-3 text-[12px] text-destructive">
|
||||
Migration failed: {error}. Your legacy projects are untouched (nothing is ever deleted). You
|
||||
can retry.
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-3 text-[11px] text-muted-foreground">You can run this again later.</p>
|
||||
<div className="mt-4 flex items-center justify-between gap-2">
|
||||
<Button variant="ghost" className="text-destructive" onClick={dontMigrate} disabled={busy} type="button">
|
||||
Don't Migrate
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => setSkipped(true)} disabled={busy} type="button">
|
||||
Skip
|
||||
</Button>
|
||||
<Button variant="primary" onClick={proceed} disabled={busy} type="button">
|
||||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{error ? "Retry" : "Proceed"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue