diff --git a/apps/gittensory-ui/src/components/site/api/try-it.test.ts b/apps/gittensory-ui/src/components/site/api/try-it.test.ts new file mode 100644 index 0000000000..37b164cba0 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/api/try-it.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { readStoredSessionToken } from "@/components/site/api/try-it"; + +describe("readStoredSessionToken legacyKey migration (rebrand key rename)", () => { + it("reads the new key directly when present, ignoring any legacy key", () => { + window.localStorage.clear(); + window.localStorage.setItem("loopover.session_token", "new-token"); + window.localStorage.setItem("gittensory.session_token", "legacy-token"); + expect(readStoredSessionToken(window.localStorage)).toBe("new-token"); + }); + + it("falls back to the legacy key when the new key is absent, and migrates the value forward", () => { + window.localStorage.clear(); + window.localStorage.setItem("gittensory.session_token", "legacy-token"); + expect(readStoredSessionToken(window.localStorage)).toBe("legacy-token"); + expect(window.localStorage.getItem("loopover.session_token")).toBe("legacy-token"); + }); + + it("returns an empty string when neither key is present", () => { + window.localStorage.clear(); + expect(readStoredSessionToken(window.localStorage)).toBe(""); + }); +}); diff --git a/apps/gittensory-ui/src/components/site/api/try-it.tsx b/apps/gittensory-ui/src/components/site/api/try-it.tsx index 219629391a..771d6ee6b5 100644 --- a/apps/gittensory-ui/src/components/site/api/try-it.tsx +++ b/apps/gittensory-ui/src/components/site/api/try-it.tsx @@ -16,7 +16,21 @@ import { useApiStatus, } from "@/lib/api/status"; -const STORAGE_KEY = "gittensory.session_token"; +const STORAGE_KEY = "loopover.session_token"; +// One-time rebrand migration fallback -- read once, then written forward to STORAGE_KEY below. +const LEGACY_STORAGE_KEY = "gittensory.session_token"; + +/** Reads the session token, falling back to (and migrating forward from) the pre-rebrand legacy key. */ +export function readStoredSessionToken(storage: Pick): string { + const stored = storage.getItem(STORAGE_KEY); + if (stored !== null) return stored; + const legacy = storage.getItem(LEGACY_STORAGE_KEY); + if (legacy !== null) { + storage.setItem(STORAGE_KEY, legacy); + return legacy; + } + return ""; +} interface Result { status: number; @@ -52,7 +66,7 @@ export function TryIt({ op, server }: { op: OpenApiOperation; server: string }) const runRef = useRef<() => Promise>(() => Promise.resolve()); useEffect(() => { - setToken(localStorage.getItem(STORAGE_KEY) ?? ""); + setToken(readStoredSessionToken(localStorage)); setResult(null); setError(null); setPathParams({}); diff --git a/apps/gittensory-ui/src/components/site/app-panels/onboarding-preview-card.test.tsx b/apps/gittensory-ui/src/components/site/app-panels/onboarding-preview-card.test.tsx index 4516ef2f9c..1570c55956 100644 --- a/apps/gittensory-ui/src/components/site/app-panels/onboarding-preview-card.test.tsx +++ b/apps/gittensory-ui/src/components/site/app-panels/onboarding-preview-card.test.tsx @@ -134,7 +134,7 @@ describe("OnboardingPreviewCard", () => { 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(); + expect(screen.queryByText(/Here's what LoopOver would have flagged/)).toBeNull(); apiFetch.mockClear(); unmount(); @@ -142,7 +142,7 @@ describe("OnboardingPreviewCard", () => { // 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(screen.queryByText(/Here's what LoopOver 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 index 6c2df935a2..fe3c3e903d 100644 --- 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 @@ -17,7 +17,9 @@ import { useLocalStorage } from "@/lib/use-local-storage"; type ReviewabilityRow = { pr: string; title: string; reason: string }; -const DISMISS_KEY = "gittensory_maintainer_onboarding_preview_dismissed"; +const DISMISS_KEY = "loopover_maintainer_onboarding_preview_dismissed"; +// One-time rebrand migration fallback -- see useLocalStorage's legacyKey param. +const LEGACY_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 @@ -40,16 +42,18 @@ function reviewabilityRowToForm(row: ReviewabilityRow): PreviewFormState | null /** * 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 + * maintainer sees "here's what LoopOver 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 [state, setState, hydrated] = useLocalStorage<{ dismissed: boolean }>( + DISMISS_KEY, + { dismissed: false }, + LEGACY_DISMISS_KEY, + ); const target = reviewability[0] ?? null; const [preview, setPreview] = useState(null); const [loading, setLoading] = useState(Boolean(target)); @@ -104,7 +108,7 @@ export function OnboardingPreviewCard({ reviewability }: { reviewability: Review

- Here's what Gittensory would have flagged + Here's what LoopOver would have flagged

{target ? ( diff --git a/apps/gittensory-ui/src/components/site/notification-readiness-card.tsx b/apps/gittensory-ui/src/components/site/notification-readiness-card.tsx index 3cf2a3943a..09a5318a18 100644 --- a/apps/gittensory-ui/src/components/site/notification-readiness-card.tsx +++ b/apps/gittensory-ui/src/components/site/notification-readiness-card.tsx @@ -29,7 +29,11 @@ export function NotificationReadinessCard() { "/v1/app/notification-model", "Notification model", ); - const [optIn, setOptIn] = useLocalStorage("gittensory_notification_opt_in", false); + const [optIn, setOptIn] = useLocalStorage( + "loopover_notification_opt_in", + false, + "gittensory_notification_opt_in", + ); const [busy, setBusy] = useState(false); const permission = typeof Notification === "undefined" ? "unsupported" : Notification.permission; diff --git a/apps/gittensory-ui/src/lib/use-local-storage.test.ts b/apps/gittensory-ui/src/lib/use-local-storage.test.ts new file mode 100644 index 0000000000..0bbd619e78 --- /dev/null +++ b/apps/gittensory-ui/src/lib/use-local-storage.test.ts @@ -0,0 +1,58 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { useLocalStorage } from "@/lib/use-local-storage"; + +describe("useLocalStorage legacyKey migration (rebrand key rename)", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("reads the new key directly when it's already present, ignoring any legacy key", async () => { + window.localStorage.setItem("new.key", JSON.stringify("from-new")); + window.localStorage.setItem("legacy.key", JSON.stringify("from-legacy")); + const { result } = renderHook(() => + useLocalStorage("new.key", "initial", "legacy.key"), + ); + await waitFor(() => expect(result.current[2]).toBe(true)); + expect(result.current[0]).toBe("from-new"); + }); + + it("falls back to the legacy key when the new key is absent, and migrates the value forward", async () => { + window.localStorage.setItem("legacy.key", JSON.stringify("carried-over")); + const { result } = renderHook(() => + useLocalStorage("new.key", "initial", "legacy.key"), + ); + await waitFor(() => expect(result.current[2]).toBe(true)); + expect(result.current[0]).toBe("carried-over"); + // Migrated forward: the new key now holds the value directly, without removing the legacy key. + expect(window.localStorage.getItem("new.key")).toBe(JSON.stringify("carried-over")); + expect(window.localStorage.getItem("legacy.key")).toBe(JSON.stringify("carried-over")); + }); + + it("uses the initial value when neither the new nor the legacy key is present", async () => { + const { result } = renderHook(() => + useLocalStorage("new.key", "initial", "legacy.key"), + ); + await waitFor(() => expect(result.current[2]).toBe(true)); + expect(result.current[0]).toBe("initial"); + expect(window.localStorage.getItem("new.key")).toBeNull(); + }); + + it("behaves exactly as before when no legacyKey is given at all", async () => { + window.localStorage.setItem("solo.key", JSON.stringify("value")); + const { result } = renderHook(() => useLocalStorage("solo.key", "initial")); + await waitFor(() => expect(result.current[2]).toBe(true)); + expect(result.current[0]).toBe("value"); + }); + + it("writes through the new key going forward after a migration", async () => { + window.localStorage.setItem("legacy.key", JSON.stringify("old-value")); + const { result } = renderHook(() => + useLocalStorage("new.key", "initial", "legacy.key"), + ); + await waitFor(() => expect(result.current[2]).toBe(true)); + act(() => result.current[1]("new-value")); + expect(window.localStorage.getItem("new.key")).toBe(JSON.stringify("new-value")); + }); +}); diff --git a/apps/gittensory-ui/src/lib/use-local-storage.ts b/apps/gittensory-ui/src/lib/use-local-storage.ts index 73d5e8cb37..dc0b7ee649 100644 --- a/apps/gittensory-ui/src/lib/use-local-storage.ts +++ b/apps/gittensory-ui/src/lib/use-local-storage.ts @@ -3,20 +3,33 @@ import { useCallback, useEffect, useState } from "react"; /** * Tiny SSR-safe localStorage hook. Reads once on mount; writes are persisted * synchronously and broadcast via a `storage` event for other tabs. + * + * `legacyKey`, when given, is read as a one-time fallback if `key` is absent + * (a rebrand key-rename migration) -- the value found there is written + * forward to `key` immediately so every later read hits the new key + * directly. The legacy key is left in place, unremoved. */ -export function useLocalStorage(key: string, initial: T) { +export function useLocalStorage(key: string, initial: T, legacyKey?: string) { const [value, setValue] = useState(initial); const [hydrated, setHydrated] = useState(false); useEffect(() => { try { const raw = window.localStorage.getItem(key); - if (raw !== null) setValue(JSON.parse(raw) as T); + if (raw !== null) { + setValue(JSON.parse(raw) as T); + } else if (legacyKey) { + const legacyRaw = window.localStorage.getItem(legacyKey); + if (legacyRaw !== null) { + setValue(JSON.parse(legacyRaw) as T); + window.localStorage.setItem(key, legacyRaw); + } + } } catch { /* ignore */ } setHydrated(true); - }, [key]); + }, [key, legacyKey]); const update = useCallback( (next: T | ((prev: T) => T)) => { diff --git a/apps/gittensory-ui/src/routes/app.index.tsx b/apps/gittensory-ui/src/routes/app.index.tsx index 5f70627b4c..ea6518227a 100644 --- a/apps/gittensory-ui/src/routes/app.index.tsx +++ b/apps/gittensory-ui/src/routes/app.index.tsx @@ -434,7 +434,7 @@ function OnboardingChecklist() { const [state, setState, hydrated] = useLocalStorage<{ dismissed: boolean; done: Record; - }>("gittensory.onboarding", { dismissed: false, done: {} }); + }>("loopover.onboarding", { dismissed: false, done: {} }, "gittensory.onboarding"); if (!hydrated || state.dismissed) return null; const completed = ONBOARDING_STEPS.filter((s) => state.done[s.id]).length; return ( diff --git a/apps/gittensory-ui/src/routes/app.runs.tsx b/apps/gittensory-ui/src/routes/app.runs.tsx index 6383d5e01e..9c1e524d52 100644 --- a/apps/gittensory-ui/src/routes/app.runs.tsx +++ b/apps/gittensory-ui/src/routes/app.runs.tsx @@ -502,7 +502,11 @@ function SavedViews({ current: { status: StatusFilter; kind: KindFilter; q: string }; onApply: (v: { status: StatusFilter; kind: KindFilter; q: string }) => void; }) { - const [views, setViews, hydrated] = useLocalStorage("gittensory.runs.views", []); + const [views, setViews, hydrated] = useLocalStorage( + "loopover.runs.views", + [], + "gittensory.runs.views", + ); const [naming, setNaming] = useState(false); const [name, setName] = useState(""); if (!hydrated) return null; diff --git a/apps/gittensory-ui/src/routes/app.workbench.tsx b/apps/gittensory-ui/src/routes/app.workbench.tsx index 9eec0295d5..5c4d438fc0 100644 --- a/apps/gittensory-ui/src/routes/app.workbench.tsx +++ b/apps/gittensory-ui/src/routes/app.workbench.tsx @@ -30,7 +30,11 @@ const LABELS: Record = { function Workbench() { const { tab } = Route.useSearch(); const navigate = useNavigate({ from: Route.fullPath }); - const [lastTab, setLastTab, hydrated] = useLocalStorage("gittensory.workbench.tab", "miner"); + const [lastTab, setLastTab, hydrated] = useLocalStorage( + "loopover.workbench.tab", + "miner", + "gittensory.workbench.tab", + ); const value: Tab = tab ?? lastTab; // Restore last tab into URL when no explicit ?tab= is present.