diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index a8e9dc411ae..fe0cb57f7bf 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -330,6 +330,15 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { return resolveDefaultEditor(input.projectId); }), + hasAny: publicProcedure.query(() => { + const row = localDb + .select({ id: projects.id }) + .from(projects) + .limit(1) + .all(); + return row.length > 0; + }), + getRecents: publicProcedure.query((): Project[] => { return localDb .select() diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts index 2bd22f3e28f..7df68e3bcb7 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts @@ -95,6 +95,15 @@ export const createQueryProcedures = () => { .sort((a, b) => a.tabOrder - b.tabOrder); }), + hasAny: publicProcedure.query(() => { + const row = localDb + .select({ id: workspaces.id }) + .from(workspaces) + .limit(1) + .all(); + return row.length > 0; + }), + getAllGrouped: publicProcedure.query(() => { type WorkspaceItem = { id: string; diff --git a/apps/desktop/src/renderer/components/V2DefaultResolver/V2DefaultResolver.tsx b/apps/desktop/src/renderer/components/V2DefaultResolver/V2DefaultResolver.tsx new file mode 100644 index 00000000000..9d9d80daea2 --- /dev/null +++ b/apps/desktop/src/renderer/components/V2DefaultResolver/V2DefaultResolver.tsx @@ -0,0 +1,31 @@ +import { useEffect } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useV2LocalOverrideStore } from "renderer/stores/v2-local-override"; + +export function V2DefaultResolver() { + const optInV2 = useV2LocalOverrideStore((s) => s.optInV2); + const isFreshInstall = useV2LocalOverrideStore((s) => s.isFreshInstall); + const setOptInV2 = useV2LocalOverrideStore((s) => s.setOptInV2); + const setIsFreshInstall = useV2LocalOverrideStore((s) => s.setIsFreshInstall); + const utils = electronTrpc.useUtils(); + + useEffect(() => { + if (optInV2 !== null && isFreshInstall !== null) return; + let cancelled = false; + void Promise.all([ + utils.workspaces.hasAny.fetch(), + utils.projects.hasAny.fetch(), + ]).then(([hasWorkspace, hasProject]) => { + if (cancelled) return; + const isFresh = !hasWorkspace && !hasProject; + const current = useV2LocalOverrideStore.getState(); + if (current.optInV2 === null) setOptInV2(isFresh); + if (current.isFreshInstall === null) setIsFreshInstall(isFresh); + }); + return () => { + cancelled = true; + }; + }, [optInV2, isFreshInstall, setOptInV2, setIsFreshInstall, utils]); + + return null; +} diff --git a/apps/desktop/src/renderer/components/V2DefaultResolver/index.ts b/apps/desktop/src/renderer/components/V2DefaultResolver/index.ts new file mode 100644 index 00000000000..90b3fa89f3f --- /dev/null +++ b/apps/desktop/src/renderer/components/V2DefaultResolver/index.ts @@ -0,0 +1 @@ +export { V2DefaultResolver } from "./V2DefaultResolver"; diff --git a/apps/desktop/src/renderer/hooks/useIsFreshInstall.ts b/apps/desktop/src/renderer/hooks/useIsFreshInstall.ts new file mode 100644 index 00000000000..f1cf53313dc --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useIsFreshInstall.ts @@ -0,0 +1,6 @@ +import { useV2LocalOverrideStore } from "renderer/stores/v2-local-override"; + +/** Returns whether this install was empty when first detected, or null if still resolving. */ +export function useIsFreshInstall(): boolean | null { + return useV2LocalOverrideStore((s) => s.isFreshInstall); +} diff --git a/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts b/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts index 93cb7f0f51d..a918c096f52 100644 --- a/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts +++ b/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts @@ -2,5 +2,5 @@ import { useV2LocalOverrideStore } from "renderer/stores/v2-local-override"; /** Returns whether v2 is currently active for this user. */ export function useIsV2CloudEnabled(): boolean { - return useV2LocalOverrideStore((s) => s.optInV2); + return useV2LocalOverrideStore((s) => s.optInV2 === true); } diff --git a/apps/desktop/src/renderer/lib/hasPriorSupersetUsage.ts b/apps/desktop/src/renderer/lib/hasPriorSupersetUsage.ts deleted file mode 100644 index 88ed37a6060..00000000000 --- a/apps/desktop/src/renderer/lib/hasPriorSupersetUsage.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * True when this install has been used before. Backed by `tabs-storage`, - * which is written the first time any workspace tab opens. Use this to - * distinguish a fresh install from a returning user. - */ -export function hasPriorSupersetUsage(): boolean { - if (typeof localStorage === "undefined") return false; - return localStorage.getItem("tabs-storage") !== null; -} diff --git a/apps/desktop/src/renderer/routes/-layout.tsx b/apps/desktop/src/renderer/routes/-layout.tsx index 4b1e1ff7957..cb1067cc0c1 100644 --- a/apps/desktop/src/renderer/routes/-layout.tsx +++ b/apps/desktop/src/renderer/routes/-layout.tsx @@ -4,6 +4,7 @@ import { PostHogSurfaceTagger } from "renderer/components/PostHogSurfaceTagger"; import { PostHogUserIdentifier } from "renderer/components/PostHogUserIdentifier"; import { TelemetrySync } from "renderer/components/TelemetrySync"; import { ThemedToaster } from "renderer/components/ThemedToaster"; +import { V2DefaultResolver } from "renderer/components/V2DefaultResolver"; import { AuthProvider } from "renderer/providers/AuthProvider"; import { ElectronTRPCProvider } from "renderer/providers/ElectronTRPCProvider"; import { PostHogProvider } from "renderer/providers/PostHogProvider"; @@ -12,6 +13,7 @@ export function RootLayout({ children }: { children: ReactNode }) { return ( + diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index c03b384865e..bd25b43819f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -15,6 +15,7 @@ import { NewWorkspaceModal } from "renderer/components/NewWorkspaceModal"; import { Paywall } from "renderer/components/Paywall"; import { useUpdateListener } from "renderer/components/UpdateToast"; import { env } from "renderer/env.renderer"; +import { useIsFreshInstall } from "renderer/hooks/useIsFreshInstall"; import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { useOnlineStatus } from "renderer/hooks/useOnlineStatus"; import { authClient, getAuthToken } from "renderer/lib/auth-client"; @@ -65,6 +66,7 @@ function AuthenticatedLayout() { const utils = electronTrpc.useUtils(); const shownWorkspaceInitWarningsRef = useRef(new Set()); const isV2CloudEnabled = useIsV2CloudEnabled(); + const isFreshInstall = useIsFreshInstall(); const requiredComplete = useOnboardingStore(selectRequiredStepsComplete); const firstIncompleteStep = useOnboardingStore(selectFirstIncompleteStep); @@ -205,7 +207,12 @@ function AuthenticatedLayout() { } const isOnSetupRoute = location.pathname.startsWith("/setup"); - if (isV2CloudEnabled && !requiredComplete && !isOnSetupRoute) { + if ( + isV2CloudEnabled && + isFreshInstall === true && + !requiredComplete && + !isOnSetupRoute + ) { return ; } diff --git a/apps/desktop/src/renderer/stores/v2-local-override.ts b/apps/desktop/src/renderer/stores/v2-local-override.ts index 135153b6210..e60f9a4f2bc 100644 --- a/apps/desktop/src/renderer/stores/v2-local-override.ts +++ b/apps/desktop/src/renderer/stores/v2-local-override.ts @@ -1,24 +1,21 @@ -import { hasPriorSupersetUsage } from "renderer/lib/hasPriorSupersetUsage"; import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; interface V2LocalOverrideState { - /** When true, the user has opted into v2. v2 is gated behind both the remote flag and this opt-in. */ - optInV2: boolean; + optInV2: boolean | null; + isFreshInstall: boolean | null; setOptInV2: (optInV2: boolean) => void; + setIsFreshInstall: (isFreshInstall: boolean) => void; } -// Fresh installs default to v2; returning v1 users default to v1 and discover -// v2 via the in-sidebar banner. Persist hydration overrides this for anyone -// with a saved override. -const initialOptInV2 = !hasPriorSupersetUsage(); - export const useV2LocalOverrideStore = create()( devtools( persist( (set) => ({ - optInV2: initialOptInV2, + optInV2: null, + isFreshInstall: null, setOptInV2: (optInV2) => set({ optInV2 }), + setIsFreshInstall: (isFreshInstall) => set({ isFreshInstall }), }), { name: "v2-local-override-v2" }, ),