diff --git a/frontend/src/renderer/components/GlobalSettingsForm.test.tsx b/frontend/src/renderer/components/GlobalSettingsForm.test.tsx
new file mode 100644
index 000000000..b33a38810
--- /dev/null
+++ b/frontend/src/renderer/components/GlobalSettingsForm.test.tsx
@@ -0,0 +1,111 @@
+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 { GlobalSettingsForm } from "./GlobalSettingsForm";
+
+const { getMock, postMock, getMigration, setMigration, getUpdate, setUpdate } = vi.hoisted(() => ({
+ getMock: vi.fn(),
+ postMock: vi.fn(),
+ getMigration: vi.fn(),
+ setMigration: vi.fn(),
+ getUpdate: vi.fn(),
+ setUpdate: 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 },
+ updateSettings: { get: getUpdate, set: setUpdate },
+ },
+}));
+
+function renderForm() {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render(
+
+
+ ,
+ );
+ return qc;
+}
+
+beforeEach(() => {
+ for (const m of [getMock, postMock, getMigration, setMigration, getUpdate, setUpdate]) m.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);
+ getUpdate.mockResolvedValue({ enabled: true, channel: "latest", nightlyAck: false });
+ setUpdate.mockResolvedValue(undefined);
+});
+
+describe("GlobalSettingsForm", () => {
+ it("renders the Updates and Migration sections", async () => {
+ renderForm();
+ expect(await screen.findByText("Updates")).toBeInTheDocument();
+ expect(screen.getByText("Migration")).toBeInTheDocument();
+ });
+
+ it("shows the nightly warning and saves the loaded channel", async () => {
+ getUpdate.mockResolvedValue({ enabled: true, channel: "nightly", nightlyAck: true });
+ renderForm();
+ expect(await screen.findByText(/Nightly builds are cut every day/i)).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Save changes" }));
+ await waitFor(() =>
+ expect(setUpdate).toHaveBeenCalledWith(expect.objectContaining({ channel: "nightly", enabled: true })),
+ );
+ });
+
+ it("hides the nightly warning on the stable channel", async () => {
+ renderForm();
+ await screen.findByText("Updates");
+ expect(screen.queryByText(/Nightly builds are cut every day/i)).not.toBeInTheDocument();
+ });
+
+ it("shows migration status and the available legacy root", async () => {
+ renderForm();
+ expect(await screen.findByText("Not migrated yet")).toBeInTheDocument();
+ expect(await screen.findByText("/home/u/.agent-orchestrator")).toBeInTheDocument();
+ });
+
+ it("Run migration imports and marks completed", async () => {
+ renderForm();
+ const btn = await screen.findByRole("button", { name: "Run migration" });
+ await userEvent.click(btn);
+ await waitFor(() => expect(postMock).toHaveBeenCalledWith("/api/v1/import"));
+ expect(setMigration).toHaveBeenCalledWith(expect.objectContaining({ status: "completed" }));
+ expect(await screen.findByText("Migration complete.")).toBeInTheDocument();
+ });
+
+ it("lets a declined user re-run the migration", async () => {
+ getMigration.mockResolvedValue({ status: "declined", lastAttemptAt: "2026-06-01T00:00:00.000Z" });
+ renderForm();
+ expect(await screen.findByText("Declined")).toBeInTheDocument();
+ const btn = await screen.findByRole("button", { name: "Run migration" });
+ expect(btn).toBeEnabled();
+ await userEvent.click(btn);
+ await waitFor(() => expect(postMock).toHaveBeenCalledWith("/api/v1/import"));
+ });
+
+ it("disables Run when no legacy install is available", async () => {
+ getMock.mockResolvedValue({ data: { available: false, legacyRoot: "" }, error: undefined });
+ renderForm();
+ expect(await screen.findByText("None found")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Run migration" })).toBeDisabled();
+ });
+
+ it("a failed import surfaces the error and marks failed", async () => {
+ postMock.mockResolvedValue({ data: undefined, error: { message: "disk full" } });
+ renderForm();
+ const btn = await screen.findByRole("button", { name: "Run migration" });
+ await userEvent.click(btn);
+ expect(await screen.findByText(/disk full/i)).toBeInTheDocument();
+ expect(setMigration).toHaveBeenCalledWith(expect.objectContaining({ status: "failed", error: "disk full" }));
+ });
+});
diff --git a/frontend/src/renderer/components/GlobalSettingsForm.tsx b/frontend/src/renderer/components/GlobalSettingsForm.tsx
index 89e280e93..bdb6ddf32 100644
--- a/frontend/src/renderer/components/GlobalSettingsForm.tsx
+++ b/frontend/src/renderer/components/GlobalSettingsForm.tsx
@@ -1,12 +1,20 @@
import { DashboardSubhead } from "./DashboardSubhead";
+import { MigrationSection } from "./MigrationSection";
+import { UpdatesSection } from "./UpdatesSection";
-// App-wide settings, shown from the sidebar when no project is selected. The
-// body is intentionally empty for now; it will be filled in incrementally.
+// App-wide settings, shown from the sidebar when no project is selected. Each
+// section is a self-contained card: Updates (auto-update channel, #2207) and
+// Migration (re-run the legacy-AO import, #2205).
export function GlobalSettingsForm() {
return (
-
+
+
+
+
+
+
);
}
diff --git a/frontend/src/renderer/components/MigrationSection.tsx b/frontend/src/renderer/components/MigrationSection.tsx
new file mode 100644
index 000000000..04f32fd2e
--- /dev/null
+++ b/frontend/src/renderer/components/MigrationSection.tsx
@@ -0,0 +1,185 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Loader2 } from "lucide-react";
+import { apiClient, apiErrorMessage } from "../lib/api-client";
+import { aoBridge } from "../lib/bridge";
+import { migrationOfferQueryKey } from "../hooks/useMigrationOffer";
+import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
+import type { MigrationState, MigrationStatus } from "../../main/app-state";
+import { Button } from "./ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
+
+export const migrationSettingsQueryKey = ["migration-settings"] as const;
+
+interface MigrationView {
+ migration: MigrationState;
+ available: boolean;
+ legacyRoot: string;
+}
+
+// fetchMigrationSettings reads the persisted decision (app marker) and asks the
+// daemon whether legacy data is present. Unlike useMigrationOffer it never
+// short-circuits on a terminal status: Settings always shows the full state so a
+// user who declined or already completed can re-run. A 501/unreachable daemon
+// resolves to "not available", never an error.
+async function fetchMigrationSettings(): Promise {
+ const migration = await aoBridge.appState.getMigration();
+ const { data, error } = await apiClient.GET("/api/v1/import");
+ return {
+ migration,
+ available: !error && (data?.available ?? false),
+ legacyRoot: data?.legacyRoot ?? "",
+ };
+}
+
+const STATUS_LABEL: Record = {
+ pending: "Not migrated yet",
+ completed: "Completed",
+ declined: "Declined",
+ failed: "Last attempt failed",
+};
+
+function statusClass(status: MigrationStatus): string {
+ switch (status) {
+ case "completed":
+ return "text-success";
+ case "failed":
+ return "text-error";
+ default:
+ return "text-muted-foreground";
+ }
+}
+
+function formatTime(iso?: string): string {
+ if (!iso) return "";
+ const d = new Date(iso);
+ return Number.isNaN(d.getTime()) ? "" : d.toLocaleString();
+}
+
+// MigrationSection is a drop-in Settings card for re-running the legacy-AO
+// import. It reads the persisted migration decision + the daemon's availability,
+// shows the last report/error, and exposes a Run / Re-run button that calls the
+// idempotent POST /api/v1/import (safe even when completed/declined/failed).
+// Issue #2205.
+export function MigrationSection() {
+ const queryClient = useQueryClient();
+ const query = useQuery({
+ queryKey: migrationSettingsQueryKey,
+ queryFn: fetchMigrationSettings,
+ });
+
+ const run = useMutation({
+ mutationFn: async () => {
+ const nowIso = () => new Date().toISOString();
+ const { data, error } = await apiClient.POST("/api/v1/import");
+ if (error) {
+ const msg = apiErrorMessage(error);
+ await aoBridge.appState.setMigration({ status: "failed", lastAttemptAt: nowIso(), error: msg });
+ throw new Error(msg);
+ }
+ const report = data?.report;
+ await aoBridge.appState.setMigration({
+ status: "completed",
+ lastAttemptAt: nowIso(),
+ completedAt: nowIso(),
+ report: report
+ ? { projectsImported: report.projectsImported, projectsSkipped: report.projectsSkipped }
+ : undefined,
+ });
+ },
+ onSettled: () => {
+ void queryClient.invalidateQueries({ queryKey: migrationSettingsQueryKey });
+ void queryClient.invalidateQueries({ queryKey: migrationOfferQueryKey });
+ void queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
+ },
+ });
+
+ const migration = query.data?.migration ?? { status: "pending" as MigrationStatus };
+ const available = query.data?.available ?? false;
+ const legacyRoot = query.data?.legacyRoot ?? "";
+ const report = migration.report;
+ const completed = migration.status === "completed";
+ const buttonLabel = run.isPending
+ ? "Running…"
+ : completed
+ ? "Re-run migration"
+ : migration.status === "failed"
+ ? "Retry migration"
+ : "Run migration";
+
+ return (
+
+
+ Migration
+
+
+
+ Import projects and orchestrator sessions from an earlier Agent Orchestrator install. Your old files are
+ never modified, and this is safe to run more than once.
+