diff --git a/frontend/src/renderer/components/MigrationPopup.test.tsx b/frontend/src/renderer/components/MigrationPopup.test.tsx new file mode 100644 index 000000000..bf9dcf73f --- /dev/null +++ b/frontend/src/renderer/components/MigrationPopup.test.tsx @@ -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( + + + , + ); + 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" })); + }); +}); diff --git a/frontend/src/renderer/components/MigrationPopup.tsx b/frontend/src/renderer/components/MigrationPopup.tsx new file mode 100644 index 000000000..7ee52b5b9 --- /dev/null +++ b/frontend/src/renderer/components/MigrationPopup.tsx @@ -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(); + + 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 ( + { + if (!next) setSkipped(true); + }} + > + + + + + Import projects from your earlier AO? + + + We found an existing install at{" "} + {legacyRoot}. Importing brings in + your projects. Your old files are never modified, and you can do this later. + + {error && ( + + Migration failed: {error}. Your legacy projects are untouched (nothing is ever deleted). You + can retry. + + )} + You can run this again later. + + + Don't Migrate + + + setSkipped(true)} disabled={busy} type="button"> + Skip + + + {busy && } + {error ? "Retry" : "Proceed"} + + + + + + + ); +}
You can run this again later.