diff --git a/.changeset/fn-7852-storage-migration-notice.md b/.changeset/fn-7852-storage-migration-notice.md new file mode 100644 index 0000000000..fed9255f08 --- /dev/null +++ b/.changeset/fn-7852-storage-migration-notice.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a one-time banner announcing the upcoming SQLite→embedded-Postgres storage change. +category: feature +dev: New self-contained `StorageMigrationNoticeBanner` in the dashboard banner cluster; permanent dismissal via `localStorage` key `fusion:storage-migration-notice-dismissed`. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 2d022863ad..602c40490f 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -789,6 +789,8 @@ Anthropic also supports a raw `ANTHROPIC_API_KEY` from a separate **Anthropic AP ## Setup Warning Banner +The dashboard banner cluster can also show a one-time storage notice announcing that the next Fusion version replaces the current SQLite data store with an embedded Postgres backend; dismissing it stores a browser-local acknowledgement so it does not reappear. + The dashboard and New Task modal show setup warnings only after readiness checks finish. AI-provider warnings appear immediately because agents cannot work without a provider. GitHub warnings are delayed per project: Fusion records the first time GitHub OAuth and authenticated `gh` CLI are both missing, waits one day, and then shows **GitHub not connected** if GitHub is still unavailable. Reconnecting GitHub clears the timer so a later disconnect starts a fresh one-day grace period. When the dashboard GitHub warning is visible, its **Connect GitHub** action opens **Settings → Authentication**. The New Task modal keeps immediate AI-provider warnings but suppresses the GitHub warning because that modal does not own the Settings navigation callback required for an actionable GitHub setup control. diff --git a/packages/dashboard/app/components/StorageMigrationNoticeBanner.css b/packages/dashboard/app/components/StorageMigrationNoticeBanner.css new file mode 100644 index 0000000000..ced2063d6d --- /dev/null +++ b/packages/dashboard/app/components/StorageMigrationNoticeBanner.css @@ -0,0 +1,73 @@ +/* === StorageMigrationNoticeBanner === */ +.storage-migration-notice-banner { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-sm); + margin: var(--space-sm) var(--space-md) 0; + padding: var(--space-sm) var(--space-md); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--todo) 12%, transparent), + color-mix(in srgb, var(--todo) 4%, transparent) + ); + border: var(--btn-border-width) solid color-mix(in srgb, var(--todo) 35%, transparent); + border-radius: var(--radius-md); + color: var(--text); +} + +.storage-migration-notice-banner__body { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-width: 0; +} + +.storage-migration-notice-banner__title { + font-weight: 600; + font-size: var(--font-size-base); + color: var(--text); +} + +.storage-migration-notice-banner__text { + margin: 0; + font-size: var(--font-size-base); + line-height: 1.5; + color: var(--text-muted); +} + +.storage-migration-notice-banner__dismiss { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-xs); + background: transparent; + color: var(--text-muted); + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + flex-shrink: 0; + line-height: 0; + transition: color var(--transition-fast), background var(--transition-fast); +} + +.storage-migration-notice-banner__dismiss:hover { + background: color-mix(in srgb, var(--text) 6%, transparent); + color: var(--text); +} + +.storage-migration-notice-banner__dismiss:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); +} + +@media (max-width: 768px) { + .storage-migration-notice-banner { + align-items: stretch; + } + + .storage-migration-notice-banner__dismiss { + align-self: flex-start; + } +} diff --git a/packages/dashboard/app/components/StorageMigrationNoticeBanner.tsx b/packages/dashboard/app/components/StorageMigrationNoticeBanner.tsx new file mode 100644 index 0000000000..3fc63155bf --- /dev/null +++ b/packages/dashboard/app/components/StorageMigrationNoticeBanner.tsx @@ -0,0 +1,65 @@ +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { X } from "lucide-react"; +import "./StorageMigrationNoticeBanner.css"; + +export const STORAGE_MIGRATION_NOTICE_DISMISS_KEY = "fusion:storage-migration-notice-dismissed"; + +/* +FNXC:StorageMigrationNotice 2026-07-12-00:00: +The dashboard must announce the next-version SQLite → embedded Postgres storage change before operators upgrade. Dismissal is global rather than project-scoped because the notice describes an app-wide on-disk storage engine change, not a per-project condition; localStorage persistence makes the one-time acknowledgement permanent on this browser profile. +*/ +function isDismissed(): boolean { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(STORAGE_MIGRATION_NOTICE_DISMISS_KEY) === "1"; + } catch { + return false; + } +} + +function persistDismissal(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_MIGRATION_NOTICE_DISMISS_KEY, "1"); + } catch { + // Ignore quota / private-mode errors — the click still hides this in-memory render. + } +} + +/** One-time informational banner for the upcoming dashboard storage backend change. */ +export function StorageMigrationNoticeBanner() { + const { t } = useTranslation("app"); + const [dismissed, setDismissed] = useState(() => isDismissed()); + + const handleDismiss = useCallback(() => { + persistDismissal(); + setDismissed(true); + }, []); + + if (dismissed) return null; + + return ( +
+
+
+ {t("storageMigrationNotice.title", "Storage update coming in the next Fusion version")} +
+

+ {t( + "storageMigrationNotice.body", + "The next Fusion version will replace the current SQLite data store with an embedded Postgres backend for data storage. No migration runs from this notice; it is an advance heads-up for operators who rely on the current .fusion/fusion.db SQLite file.", + )} +

+
+ +
+ ); +} diff --git a/packages/dashboard/app/components/__tests__/StorageMigrationNoticeBanner.test.tsx b/packages/dashboard/app/components/__tests__/StorageMigrationNoticeBanner.test.tsx new file mode 100644 index 0000000000..c1288d19fe --- /dev/null +++ b/packages/dashboard/app/components/__tests__/StorageMigrationNoticeBanner.test.tsx @@ -0,0 +1,82 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + STORAGE_MIGRATION_NOTICE_DISMISS_KEY, + StorageMigrationNoticeBanner, +} from "../StorageMigrationNoticeBanner"; + +const title = "Storage update coming in the next Fusion version"; +const body = /next Fusion version will replace the current SQLite data store with an embedded Postgres backend/i; +const dismissLabel = "Dismiss storage update notice"; +const originalLocalStorage = window.localStorage; + +describe("StorageMigrationNoticeBanner", () => { + beforeEach(() => { + Object.defineProperty(window, "localStorage", { + configurable: true, + value: originalLocalStorage, + }); + window.localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + Object.defineProperty(window, "localStorage", { + configurable: true, + value: originalLocalStorage, + }); + window.localStorage.clear(); + }); + + it("renders the storage notice when the dismissal key is absent", () => { + render(); + + expect(screen.getByRole("status")).toBeInTheDocument(); + expect(screen.getByText(title)).toBeInTheDocument(); + expect(screen.getByText(body)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: dismissLabel })).toBeInTheDocument(); + }); + + it("hides immediately and persists the dismissal key when dismissed", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: dismissLabel })); + + expect(window.localStorage.getItem(STORAGE_MIGRATION_NOTICE_DISMISS_KEY)).toBe("1"); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: dismissLabel })).not.toBeInTheDocument(); + }); + + it("returns null on a fresh mount when the dismissal key is already persisted", () => { + window.localStorage.setItem(STORAGE_MIGRATION_NOTICE_DISMISS_KEY, "1"); + + render(); + + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.queryByText(title)).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: dismissLabel })).not.toBeInTheDocument(); + }); + + it("does not crash when localStorage getItem or setItem throws", () => { + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + getItem: () => { + throw new Error("private mode getItem denied"); + }, + setItem: () => { + throw new Error("private mode setItem denied"); + }, + }, + }); + + expect(() => render()).not.toThrow(); + expect(screen.getByRole("status")).toBeInTheDocument(); + + expect(() => { + fireEvent.click(screen.getByRole("button", { name: dismissLabel })); + }).not.toThrow(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard/app/components/dashboard/DashboardBanners.tsx b/packages/dashboard/app/components/dashboard/DashboardBanners.tsx index 465804b31a..30153c74ae 100644 --- a/packages/dashboard/app/components/dashboard/DashboardBanners.tsx +++ b/packages/dashboard/app/components/dashboard/DashboardBanners.tsx @@ -10,6 +10,7 @@ import { EngineStatusBanner } from "../EngineStatusBanner"; import { OAuthReloginBanner } from "../OAuthReloginBanner"; import { SessionNotificationBanner } from "../SessionNotificationBanner"; import { CliBinaryInstallBanner } from "../CliBinaryInstallBanner"; +import { StorageMigrationNoticeBanner } from "../StorageMigrationNoticeBanner"; import { OnboardingResumeCard } from "../OnboardingResumeCard"; import { PostOnboardingRecommendations } from "../PostOnboardingRecommendations"; import { UpdateAvailableBanner } from "../UpdateAvailableBanner"; @@ -101,9 +102,13 @@ export function DashboardBanners({ /> )} {viewMode === "project" && currentProject && ( - openSettingsWithNav("general" as SectionId)} - /> + <> + {/* FNXC:StorageMigrationNotice 2026-07-12-00:00: Keep the one-time storage-backend announcement beside other project-scoped passive notices while its localStorage dismissal remains app-wide. */} + + openSettingsWithNav("general" as SectionId)} + /> + )} {viewMode === "project" && currentProject && showOnboardingResumeCard && ( diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index ec654ec410..2909fe420f 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -1420,6 +1420,11 @@ "versionMismatchPrefix": "Your installed", "versionMismatchSuffix": "Update to stay in sync." }, + "storageMigrationNotice": { + "body": "The next Fusion version will replace the current SQLite data store with an embedded Postgres backend for data storage. No migration runs from this notice; it is an advance heads-up for operators who rely on the current .fusion/fusion.db SQLite file.", + "dismissLabel": "Dismiss storage update notice", + "title": "Storage update coming in the next Fusion version" + }, "cliBinary": { "binaryLabel": "Binary:", "checking": "Checking…",