diff --git a/apps/desktop/src/renderer/components/V2AvailableBanner/V2AvailableBanner.tsx b/apps/desktop/src/renderer/components/V2AvailableBanner/V2AvailableBanner.tsx index a66259e1eca..d6c166402a4 100644 --- a/apps/desktop/src/renderer/components/V2AvailableBanner/V2AvailableBanner.tsx +++ b/apps/desktop/src/renderer/components/V2AvailableBanner/V2AvailableBanner.tsx @@ -1,19 +1,19 @@ import { SidebarCard } from "@superset/ui/sidebar-card"; +import { useNavigate } from "@tanstack/react-router"; import { AnimatePresence, motion } from "framer-motion"; import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { track } from "renderer/lib/analytics"; import { useV2AvailableBannerStore } from "renderer/stores/v2-available-banner"; -import { useV2LocalOverrideStore } from "renderer/stores/v2-local-override"; export function V2AvailableBanner() { const isV2CloudEnabled = useIsV2CloudEnabled(); const dismissed = useV2AvailableBannerStore((s) => s.dismissed); const dismiss = useV2AvailableBannerStore((s) => s.dismiss); - const setOptInV2 = useV2LocalOverrideStore((s) => s.setOptInV2); + const navigate = useNavigate(); - function handleSwitch() { - track("surface_toggled", { from: "v1", to: "v2", source: "v1_banner" }); - setOptInV2(true); + function handleManage() { + track("v2_banner_manage_clicked"); + navigate({ to: "/settings/experimental" }); } function handleDismiss() { @@ -21,6 +21,8 @@ export function V2AvailableBanner() { dismiss(); } + if (isV2CloudEnabled) return null; + return ( {!dismissed && ( @@ -34,9 +36,9 @@ export function V2AvailableBanner() { diff --git a/apps/desktop/src/renderer/hooks/useEnsureV2Project/index.ts b/apps/desktop/src/renderer/hooks/useEnsureV2Project/index.ts new file mode 100644 index 00000000000..858e388d8e7 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useEnsureV2Project/index.ts @@ -0,0 +1,4 @@ +export { + type EnsureV2ProjectResult, + useEnsureV2Project, +} from "./useEnsureV2Project"; diff --git a/apps/desktop/src/renderer/hooks/useEnsureV2Project/useEnsureV2Project.ts b/apps/desktop/src/renderer/hooks/useEnsureV2Project/useEnsureV2Project.ts new file mode 100644 index 00000000000..3857a09c03d --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useEnsureV2Project/useEnsureV2Project.ts @@ -0,0 +1,66 @@ +import { useCallback } from "react"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; + +export interface EnsureV2ProjectResult { + hostUrl: string; + projectId: string; + repoPath: string; + mainWorkspaceId: string | null; +} + +export function useEnsureV2Project(): (args: { + repoPath: string; + name: string; +}) => Promise { + const { activeHostUrl } = useLocalHostService(); + + return useCallback( + async ({ repoPath, name }) => { + if (!activeHostUrl) { + throw new Error("No active host service"); + } + const hostService = getHostServiceClientByUrl(activeHostUrl); + + const found = await hostService.project.findByPath.query({ repoPath }); + const candidate = found.candidates[0]; + if (candidate) { + try { + const setupResult = await hostService.project.setup.mutate({ + projectId: candidate.id, + mode: { kind: "import", repoPath }, + }); + return { + hostUrl: activeHostUrl, + projectId: candidate.id, + repoPath: setupResult.repoPath, + mainWorkspaceId: setupResult.mainWorkspaceId, + }; + } catch (err) { + // findByPath returns local sqlite rows even when no cloud v2 project + // exists for that id; setup → v2Project.get → NOT_FOUND. Only that + // case is safe to fall through to create — every other error + // (network, auth, 5xx) must propagate so retries don't silently mint + // duplicate cloud projects. + const code = (err as { data?: { code?: string } } | null | undefined) + ?.data?.code; + if (code !== "NOT_FOUND") { + throw err; + } + } + } + + const created = await hostService.project.create.mutate({ + name, + mode: { kind: "importLocal", repoPath }, + }); + return { + hostUrl: activeHostUrl, + projectId: created.projectId, + repoPath: created.repoPath, + mainWorkspaceId: created.mainWorkspaceId, + }; + }, + [activeHostUrl], + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/page.tsx index 0952ee7ad75..af0f66f5fbd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/page.tsx @@ -4,11 +4,14 @@ import { Spinner } from "@superset/ui/spinner"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { GoGitBranch } from "react-icons/go"; -import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; +import { useEnsureV2Project } from "renderer/hooks/useEnsureV2Project"; import { track } from "renderer/lib/analytics"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { useImportExternalWorktrees } from "renderer/react-query/workspaces/useImportExternalWorktrees"; +import { useFinalizeProjectSetup } from "renderer/react-query/projects/useFinalizeProjectSetup"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { STEP_ROUTES, useOnboardingStore } from "renderer/stores/onboarding"; +import { useWorkspaceCreates } from "renderer/stores/workspace-creates"; import { SetupButton } from "../components/SetupButton"; import { StepHeader, StepShell } from "../components/StepShell"; import { @@ -33,13 +36,7 @@ function OnboardingAdoptWorktreesPage() { const goTo = useOnboardingStore((s) => s.goTo); const markComplete = useOnboardingStore((s) => s.markComplete); const markSkipped = useOnboardingStore((s) => s.markSkipped); - const manualWalkthrough = useOnboardingStore((s) => s.manualWalkthrough); - const setManualWalkthrough = useOnboardingStore( - (s) => s.setManualWalkthrough, - ); - const utils = electronTrpc.useUtils(); - const isV2CloudEnabled = useIsV2CloudEnabled(); const { data: projects, isPending } = electronTrpc.projects.getRecents.useQuery(); @@ -47,48 +44,11 @@ function OnboardingAdoptWorktreesPage() { goTo("adopt-worktrees"); }, [goTo]); - const navigateAfterFlow = useCallback( - async (replace: boolean) => { - try { - const grouped = await utils.workspaces.getAllGrouped.fetch(); - const allWorkspaces = grouped.flatMap((g) => g.workspaces); - const lastViewedId = localStorage.getItem("lastViewedWorkspaceId"); - const candidates = isV2CloudEnabled - ? allWorkspaces.filter((w) => w.type === "worktree") - : allWorkspaces; - const target = - candidates.find((w) => w.id === lastViewedId) ?? candidates[0]; - if (target) { - if (isV2CloudEnabled) { - navigate({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId: target.id }, - replace, - }); - } else { - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId: target.id }, - replace, - }); - } - return; - } - } catch { - // fall through to project / welcome routing - } - const project = projects?.[0]; - if (project) { - navigate({ - to: "/project/$projectId", - params: { projectId: project.id }, - replace, - }); - return; - } - navigate({ to: "/welcome", replace }); + const goToDashboard = useCallback( + (replace: boolean) => { + navigate({ to: "/v2-workspaces", replace }); }, - [utils, isV2CloudEnabled, navigate, projects], + [navigate], ); const finishFlow = useCallback(() => { @@ -98,9 +58,8 @@ function OnboardingAdoptWorktreesPage() { duration_ms: startedAt ? Date.now() - startedAt : null, }); markComplete("adopt-worktrees"); - setManualWalkthrough(false); - void navigateAfterFlow(true); - }, [markComplete, setManualWalkthrough, navigateAfterFlow]); + goToDashboard(true); + }, [markComplete, goToDashboard]); const skipFlow = useCallback(() => { const startedAt = useOnboardingStore.getState().startedAt; @@ -109,9 +68,8 @@ function OnboardingAdoptWorktreesPage() { duration_ms: startedAt ? Date.now() - startedAt : null, }); markSkipped("adopt-worktrees"); - setManualWalkthrough(false); - void navigateAfterFlow(true); - }, [markSkipped, setManualWalkthrough, navigateAfterFlow]); + goToDashboard(true); + }, [markSkipped, goToDashboard]); if (isPending) { return ( @@ -121,52 +79,48 @@ function OnboardingAdoptWorktreesPage() { ); } - if (!projects || projects.length === 0) { - return ; - } - return ( ({ id: p.id, name: p.name }))} + projects={(projects ?? []).map((p) => ({ + id: p.id, + name: p.name, + mainRepoPath: p.mainRepoPath, + }))} onSkip={skipFlow} onFinish={finishFlow} - manualWalkthrough={manualWalkthrough} /> ); } -function AutoAdvance({ onAdvance }: { onAdvance: () => void }) { - useEffect(() => { - onAdvance(); - }, [onAdvance]); - return ( -
- -
- ); -} - interface ProjectResult { worktrees: ExternalWorktree[]; loaded: boolean; } interface AdoptWorktreesContentProps { - projects: { id: string; name: string }[]; + projects: { id: string; name: string; mainRepoPath: string }[]; onSkip: () => void; onFinish: () => void; - manualWalkthrough: boolean; } function AdoptWorktreesContent({ projects, onSkip, onFinish, - manualWalkthrough, }: AdoptWorktreesContentProps) { - const importExternalWorktrees = useImportExternalWorktrees(); + const { submit } = useWorkspaceCreates(); + const { machineId, activeHostUrl } = useLocalHostService(); + const ensureV2Project = useEnsureV2Project(); + const finalizeProjectSetup = useFinalizeProjectSetup(); + const { ensureWorkspaceInSidebar } = useDashboardSidebarState(); const [results, setResults] = useState>({}); const [selected, setSelected] = useState({}); + const [isImporting, setIsImporting] = useState(false); + const [progress, setProgress] = useState<{ + current: number; + total: number; + } | null>(null); + const hostNotReady = !activeHostUrl; const allLoaded = projects.every((p) => results[p.id]?.loaded); const total = useMemo( @@ -179,10 +133,6 @@ function AdoptWorktreesContent({ ); const totalSelected = useMemo(() => countSelected(selected), [selected]); - useEffect(() => { - if (allLoaded && total === 0 && !manualWalkthrough) onFinish(); - }, [allLoaded, total, manualWalkthrough, onFinish]); - const handleResult = useCallback( (projectId: string, worktrees: ExternalWorktree[]) => { setResults((prev) => ({ @@ -220,23 +170,73 @@ function AdoptWorktreesContent({ ); const handleImportSelected = async () => { + if (!machineId) { + toast.error("No active host"); + return; + } + const totalToImport = totalSelected; + setIsImporting(true); + setProgress({ current: 0, total: totalToImport }); let totalImported = 0; - for (const project of projects) { - const paths = Array.from(selected[project.id] ?? []); - if (paths.length === 0) continue; - try { - const result = await importExternalWorktrees.mutateAsync({ - projectId: project.id, - paths, - }); - totalImported += result.imported; - } catch (err) { - toast.error( - err instanceof Error - ? err.message - : `Failed to import worktrees for ${project.name}`, - ); + try { + for (const project of projects) { + const paths = Array.from(selected[project.id] ?? []); + if (paths.length === 0) continue; + const projectResult = results[project.id]; + if (!projectResult) continue; + + let v2ProjectId: string; + try { + const ensureResult = await ensureV2Project({ + repoPath: project.mainRepoPath, + name: project.name, + }); + v2ProjectId = ensureResult.projectId; + finalizeProjectSetup(ensureResult.hostUrl, { + projectId: ensureResult.projectId, + repoPath: ensureResult.repoPath, + mainWorkspaceId: ensureResult.mainWorkspaceId, + }); + } catch (err) { + toast.error( + err instanceof Error + ? `Could not link "${project.name}" to v2: ${err.message}` + : `Could not link "${project.name}" to v2`, + ); + continue; + } + + for (const path of paths) { + const wt = projectResult.worktrees.find((w) => w.path === path); + if (!wt) continue; + const result = await submit({ + hostId: machineId, + snapshot: { + id: crypto.randomUUID(), + projectId: v2ProjectId, + name: wt.branch, + branch: wt.branch, + }, + }); + if (result.ok) { + totalImported++; + ensureWorkspaceInSidebar(result.workspaceId, v2ProjectId); + } else { + toast.error(`Failed to import ${wt.branch}: ${result.error}`); + } + setProgress({ current: totalImported, total: totalToImport }); + } } + } finally { + setIsImporting(false); + setProgress(null); + } + + const hadSelections = projects.some((p) => (selected[p.id]?.size ?? 0) > 0); + if (hadSelections && totalImported === 0) { + // All selected imports failed. Errors were already toasted; keep the + // user here to retry rather than yanking them into the dashboard. + return; } if (totalImported > 0) { toast.success( @@ -253,11 +253,13 @@ function AdoptWorktreesContent({ @@ -292,22 +294,18 @@ function AdoptWorktreesContent({ - {importExternalWorktrees.isPending - ? "Importing…" - : totalSelected === 0 - ? "Select worktrees" - : `Import ${totalSelected} selected`} + {isImporting && progress + ? `Importing ${progress.current} of ${progress.total}…` + : hostNotReady + ? "Connecting…" + : totalSelected === 0 + ? "Select worktrees" + : `Import ${totalSelected} selected`} - + Skip for now diff --git a/apps/desktop/src/renderer/routes/_authenticated/setup/components/SetupButton/SetupButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/setup/components/SetupButton/SetupButton.tsx index e34cd5eafba..21f526f5a06 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/setup/components/SetupButton/SetupButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/setup/components/SetupButton/SetupButton.tsx @@ -1,35 +1,36 @@ +import { Button } from "@superset/ui/button"; import { cn } from "@superset/ui/utils"; -import type { ButtonHTMLAttributes, ReactNode } from "react"; +import type { ComponentProps } from "react"; type Variant = "primary" | "secondary" | "link"; -interface SetupButtonProps extends ButtonHTMLAttributes { +interface SetupButtonProps + extends Omit, "variant" | "size"> { variant?: Variant; - children: ReactNode; } -const VARIANT_STYLES: Record = { - primary: - "relative h-[28px] w-full rounded-[4px] border border-[rgba(255,136,70,0.8)] bg-[rgba(255,91,0,0.8)] px-2 text-[12px] font-medium text-[#eae8e6] shadow-[inset_0_1px_6.9px_0_rgba(255,255,255,0.14)] transition-colors hover:bg-[rgba(255,91,0,0.95)] disabled:opacity-60", - secondary: - "relative h-[28px] w-full rounded-[4px] border border-[#2a2827] bg-[#201e1c] px-2 text-[12px] font-medium text-[#eae8e6] shadow-[inset_0_1px_6.9px_0_rgba(255,255,255,0.1)] transition-colors hover:bg-[#2a2827] disabled:opacity-60", - link: "text-[12px] font-medium text-[#a8a5a3] underline-offset-4 transition-colors hover:text-[#eae8e6] hover:underline", -}; +const VARIANT_MAP = { + primary: "default", + secondary: "secondary", + link: "link", +} as const satisfies Record["variant"]>; export function SetupButton({ variant = "primary", className, - children, type = "button", + children, ...rest }: SetupButtonProps) { return ( - + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/setup/gh-cli/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/setup/gh-cli/page.tsx index 0adbe361492..2995f0ee477 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/setup/gh-cli/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/setup/gh-cli/page.tsx @@ -17,9 +17,6 @@ function OnboardingGhCliPage() { const goTo = useOnboardingStore((s) => s.goTo); const markComplete = useOnboardingStore((s) => s.markComplete); const markSkipped = useOnboardingStore((s) => s.markSkipped); - const completed = useOnboardingStore((s) => s.completed["gh-cli"]); - const skipped = useOnboardingStore((s) => s.skipped["gh-cli"]); - const manualWalkthrough = useOnboardingStore((s) => s.manualWalkthrough); const { data: ghStatus, @@ -32,19 +29,6 @@ function OnboardingGhCliPage() { goTo("gh-cli"); }, [goTo]); - const shouldAutoAdvance = - !completed && - !skipped && - !manualWalkthrough && - ghStatus?.installed === true; - - useEffect(() => { - if (shouldAutoAdvance) { - markComplete("gh-cli"); - navigate({ to: STEP_ROUTES.permissions, replace: true }); - } - }, [shouldAutoAdvance, markComplete, navigate]); - const handleSkip = () => { markSkipped("gh-cli"); navigate({ to: STEP_ROUTES.permissions }); @@ -54,7 +38,7 @@ function OnboardingGhCliPage() { navigate({ to: STEP_ROUTES.permissions }); }; - if (isPending || shouldAutoAdvance) { + if (isPending) { return (
diff --git a/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx index 9392e36d0f8..ae531495760 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx @@ -1,23 +1,17 @@ -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@superset/ui/alert-dialog"; import { toast } from "@superset/ui/sonner"; import { Spinner } from "@superset/ui/spinner"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; -import { LuFolder, LuX } from "react-icons/lu"; -import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { useOpenProject } from "renderer/react-query/projects/useOpenProject"; +import { LuFolder } from "react-icons/lu"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; +import { useFolderFirstImport } from "renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { STEP_ROUTES, useOnboardingStore } from "renderer/stores/onboarding"; +import { MOCK_ORG_ID } from "shared/constants"; import { SetupButton } from "../components/SetupButton"; import { StepHeader, StepShell, SupersetPill } from "../components/StepShell"; import { SupersetIcon } from "../providers/components/SupersetIcon"; @@ -31,91 +25,45 @@ function OnboardingProjectPage() { const goTo = useOnboardingStore((s) => s.goTo); const markComplete = useOnboardingStore((s) => s.markComplete); const markSkipped = useOnboardingStore((s) => s.markSkipped); - const completed = useOnboardingStore((s) => s.completed.project); - const manualWalkthrough = useOnboardingStore((s) => s.manualWalkthrough); - const setManualWalkthrough = useOnboardingStore( - (s) => s.setManualWalkthrough, + + const collections = useCollections(); + const { data: session } = authClient.useSession(); + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); + + const { data: projects = [], isLoading } = useLiveQuery( + (q) => + q + .from({ projects: collections.v2Projects }) + .where(({ projects }) => + eq(projects.organizationId, activeOrganizationId ?? ""), + ) + .select(({ projects }) => ({ + id: projects.id, + name: projects.name, + repoCloneUrl: projects.repoCloneUrl, + })), + [collections, activeOrganizationId], ); - const { data: projects, isPending } = - electronTrpc.projects.getRecents.useQuery(); - const { openNew, isPending: isOpenPending } = useOpenProject(); - const utils = electronTrpc.useUtils(); - const isV2CloudEnabled = useIsV2CloudEnabled(); - const closeProject = electronTrpc.projects.close.useMutation({ - onSuccess: async () => { - await utils.projects.getRecents.invalidate(); - }, + const folderImport = useFolderFirstImport({ + onError: (message) => toast.error(message), }); - - const handleRemoveProject = async (id: string, name: string) => { - try { - await closeProject.mutateAsync({ id }); - toast.success(`Removed ${name}`); - } catch (err) { - toast.error( - err instanceof Error ? err.message : "Failed to remove project", - ); - } - }; + const { activeHostUrl } = useLocalHostService(); + const hostReady = activeHostUrl !== null; useEffect(() => { goTo("project"); }, [goTo]); - const projectCount = projects?.length ?? 0; + const projectCount = projects.length; const hasProjects = projectCount > 0; - const shouldAutoAdvance = !completed && !manualWalkthrough && hasProjects; - - useEffect(() => { - if (shouldAutoAdvance) { - markComplete("project"); - navigate({ to: STEP_ROUTES["adopt-worktrees"], replace: true }); - } - }, [shouldAutoAdvance, markComplete, navigate]); - - // After creating a new project, route to the project page in v2 — that's - // where the user creates their first proper worktree workspace (which sets - // up the v2 pane layout). The default "branch" workspace auto-created by - // openNew → ensureMainWorkspace has no pane layout, so navigating there - // renders an empty workspace surface. In v1, navigate to the existing - // branch workspace as before. - const openProjectInWorkspace = async (projectId: string) => { - if (isV2CloudEnabled) { - navigate({ - to: "/project/$projectId", - params: { projectId }, - }); - return; - } - try { - const grouped = await utils.workspaces.getAllGrouped.fetch(); - const wsForProject = grouped - .flatMap((g) => g.workspaces) - .find((w) => w.projectId === projectId); - if (wsForProject) { - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId: wsForProject.id }, - }); - return; - } - } catch { - // fall through - } - navigate({ - to: "/project/$projectId", - params: { projectId }, - }); - }; const handleSelectNewRepo = async () => { - const created = await openNew(); - const project = created[0]; - if (project) { + const result = await folderImport.start(); + if (result) { markComplete("project"); - setManualWalkthrough(false); - await openProjectInWorkspace(project.id); } }; @@ -129,7 +77,7 @@ function OnboardingProjectPage() { navigate({ to: STEP_ROUTES["adopt-worktrees"] }); }; - if (isPending || shouldAutoAdvance) { + if (isLoading) { return (
@@ -145,7 +93,7 @@ function OnboardingProjectPage() { ); - if (hasProjects && projects) { + if (hasProjects) { return ( (
@@ -168,43 +116,12 @@ function OnboardingProjectPage() {

{project.name}

-

- {project.mainRepoPath} -

+ {project.repoCloneUrl && ( +

+ {project.repoCloneUrl} +

+ )}
- - - - - - - - Remove {project.name}? - - - This removes the project and its tracked workspaces from - Superset. The folder on disk and your git history are - untouched — you can re-add it any time. - - - - Cancel - - handleRemoveProject(project.id, project.name) - } - > - Remove - - - -
))}
@@ -217,15 +134,9 @@ function OnboardingProjectPage() { - {isOpenPending ? "Opening…" : "Select new repo"} - - navigate({ to: "/new-project" })} - > - Clone from GitHub + {hostReady ? "Select new repo" : "Connecting…"} Skip for now @@ -244,14 +155,8 @@ function OnboardingProjectPage() { />
- - {isOpenPending ? "Opening…" : "Select new repo"} - - navigate({ to: "/new-project" })} - > - Clone from GitHub + + {hostReady ? "Select new repo" : "Connecting…"} Skip for now diff --git a/apps/desktop/src/renderer/routes/_authenticated/setup/providers/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/setup/providers/page.tsx index 827f619647f..8e44bbf71bf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/setup/providers/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/setup/providers/page.tsx @@ -1,7 +1,7 @@ import { chatServiceTrpc } from "@superset/chat/client"; import { Spinner } from "@superset/ui/spinner"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { type ReactNode, useEffect, useRef, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import { LuKeyRound, LuSettings } from "react-icons/lu"; import { STEP_ROUTES, useOnboardingStore } from "renderer/stores/onboarding"; import { SetupButton } from "../components/SetupButton"; @@ -21,8 +21,6 @@ function OnboardingProvidersPage() { const goTo = useOnboardingStore((s) => s.goTo); const markComplete = useOnboardingStore((s) => s.markComplete); const markSkipped = useOnboardingStore((s) => s.markSkipped); - const completed = useOnboardingStore((s) => s.completed.providers); - const manualWalkthrough = useOnboardingStore((s) => s.manualWalkthrough); const { data: anthropicAuthStatus, isPending: isAnthropicPending } = chatServiceTrpc.auth.getAnthropicStatus.useQuery(); @@ -36,33 +34,24 @@ function OnboardingProvidersPage() { const isStatusPending = isAnthropicPending || isOpenAIPending; const atLeastOneConnected = claudeConnected || codexConnected; - const wasConfiguredOnMount = useRef(null); - useEffect(() => { - if (!isStatusPending && wasConfiguredOnMount.current === null) { - wasConfiguredOnMount.current = atLeastOneConnected; - } - }, [isStatusPending, atLeastOneConnected]); - const [claudeMethod, setClaudeMethod] = useState("oauth"); const [codexMethod, setCodexMethod] = useState("oauth"); const [reconfiguringClaude, setReconfiguringClaude] = useState(false); const [reconfiguringCodex, setReconfiguringCodex] = useState(false); + const [activeProvider, setActiveProvider] = useState<"claude" | "codex">( + "claude", + ); useEffect(() => { - goTo("providers"); - }, [goTo]); - - const shouldAutoAdvance = - !completed && !manualWalkthrough && wasConfiguredOnMount.current === true; + if (claudeConnected && !codexConnected) setActiveProvider("codex"); + else if (!claudeConnected && codexConnected) setActiveProvider("claude"); + }, [claudeConnected, codexConnected]); useEffect(() => { - if (shouldAutoAdvance) { - markComplete("providers"); - navigate({ to: STEP_ROUTES["gh-cli"], replace: true }); - } - }, [shouldAutoAdvance, markComplete, navigate]); + goTo("providers"); + }, [goTo]); - if (isStatusPending || shouldAutoAdvance) { + if (isStatusPending) { return (
@@ -93,115 +82,158 @@ function OnboardingProvidersPage() { ? "Add another provider or continue to the next step." : "Connect Claude Code, Codex, or both to get started."; + const inAnyStep = + !claudeConnected || + !codexConnected || + reconfiguringClaude || + reconfiguringCodex; + const effectiveProvider: "claude" | "codex" = reconfiguringClaude + ? "claude" + : reconfiguringCodex + ? "codex" + : activeProvider; + const showBothCompact = !inAnyStep; + const showClaude = showBothCompact || effectiveProvider === "claude"; + const showCodex = showBothCompact || effectiveProvider === "codex"; + // Only offer the switcher when neither provider is connected. Once one is + // connected, the auto-switch effect points activeProvider at the unconnected + // one, so "Set up instead" would just drop the user into a + // Reconfigure panel for an already-working provider. + const showSwitcher = + !claudeConnected && + !codexConnected && + !reconfiguringClaude && + !reconfiguringCodex; + const otherLabel = effectiveProvider === "claude" ? "Codex" : "Claude Code"; + return ( - - handleConnect("/setup/providers/claude-code", claudeMethod) - } - onReconfigure={() => setReconfiguringClaude(true)} - onCancelReconfigure={() => setReconfiguringClaude(false)} - connectedPanel={ - - } - title="Claude Code is connected" - /> - } - options={ - <> - + handleConnect("/setup/providers/claude-code", claudeMethod) + } + onReconfigure={() => setReconfiguringClaude(true)} + onCancelReconfigure={() => setReconfiguringClaude(false)} + connectedPanel={ + + } - title="Claude Pro/Max" - description="Use your Claude subscription for unlimited access." - recommended - selected={claudeMethod === "oauth"} - onSelect={() => setClaudeMethod("oauth")} - /> - } />} - title="Anthropic API Key" - description="Pay-as-you-go with your own API key." - selected={claudeMethod === "api-key"} - onSelect={() => setClaudeMethod("api-key")} - /> - } />} - title="Custom Model" - description="Use a custom base URL and model." - selected={claudeMethod === "custom"} - onSelect={() => setClaudeMethod("custom")} + title="Claude Code is connected" /> - - } - /> - - handleConnect("/setup/providers/codex", codexMethod)} - onReconfigure={() => setReconfiguringCodex(true)} - onCancelReconfigure={() => setReconfiguringCodex(false)} - connectedPanel={ - + + } + title="Claude Pro/Max" + description="Use your Claude subscription for unlimited access." + recommended + selected={claudeMethod === "oauth"} + onSelect={() => setClaudeMethod("oauth")} /> - } - title="Codex is connected" - /> - } - options={ - <> - } />} + title="Anthropic API Key" + description="Pay-as-you-go with your own API key." + selected={claudeMethod === "api-key"} + onSelect={() => setClaudeMethod("api-key")} + /> + } />} + title="Custom Model" + description="Use a custom base URL and model." + selected={claudeMethod === "custom"} + onSelect={() => setClaudeMethod("custom")} + /> + + } + /> + )} + + {showCodex && ( + handleConnect("/setup/providers/codex", codexMethod)} + onReconfigure={() => setReconfiguringCodex(true)} + onCancelReconfigure={() => setReconfiguringCodex(false)} + connectedPanel={ + } - title="ChatGPT Plus/Pro" - description="Use your ChatGPT subscription via Codex." - recommended - selected={codexMethod === "oauth"} - onSelect={() => setCodexMethod("oauth")} - /> - } />} - title="OpenAI API Key" - description="Pay-as-you-go with your own API key." - selected={codexMethod === "api-key"} - onSelect={() => setCodexMethod("api-key")} + title="Codex is connected" /> - } />} - title="Custom Model" - description="Use a custom base URL and model." - selected={codexMethod === "custom"} - onSelect={() => setCodexMethod("custom")} - /> - - } - /> + } + options={ + <> + + } + title="ChatGPT Plus/Pro" + description="Use your ChatGPT subscription via Codex." + recommended + selected={codexMethod === "oauth"} + onSelect={() => setCodexMethod("oauth")} + /> + } />} + title="OpenAI API Key" + description="Pay-as-you-go with your own API key." + selected={codexMethod === "api-key"} + onSelect={() => setCodexMethod("api-key")} + /> + } />} + title="Custom Model" + description="Use a custom base URL and model." + selected={codexMethod === "custom"} + onSelect={() => setCodexMethod("custom")} + /> + + } + /> + )}
- {atLeastOneConnected && ( + {atLeastOneConnected && !inAnyStep && ( Continue )} + {showSwitcher && ( + + setActiveProvider( + effectiveProvider === "claude" ? "codex" : "claude", + ) + } + > + Set up {otherLabel} instead + + )} Skip for now diff --git a/apps/desktop/src/renderer/stores/onboarding/onboardingStore.ts b/apps/desktop/src/renderer/stores/onboarding/onboardingStore.ts index 2fbffdfa528..b862acedd3c 100644 --- a/apps/desktop/src/renderer/stores/onboarding/onboardingStore.ts +++ b/apps/desktop/src/renderer/stores/onboarding/onboardingStore.ts @@ -44,12 +44,6 @@ interface OnboardingState { skipped: Record; startedAt: number | null; completedAt: number | null; - /** - * When true, the user explicitly restarted onboarding from Settings. - * Steps must NOT auto-advance based on already-satisfied prerequisites — - * the user wants to walk through. Cleared when the flow finishes. - */ - manualWalkthrough: boolean; } interface OnboardingActions { @@ -59,7 +53,6 @@ interface OnboardingActions { next: () => OnboardingStep | null; back: () => OnboardingStep | null; reset: () => void; - setManualWalkthrough: (value: boolean) => void; } type OnboardingStore = OnboardingState & OnboardingActions; @@ -70,7 +63,6 @@ const initialState: OnboardingState = { skipped: { ...STEP_FLAGS_INITIAL }, startedAt: null, completedAt: null, - manualWalkthrough: false, }; function getNextStep(step: OnboardingStep): OnboardingStep | null { @@ -150,10 +142,8 @@ export const useOnboardingStore = create()( skipped: { ...STEP_FLAGS_INITIAL }, startedAt: null, completedAt: null, - manualWalkthrough: false, }); }, - setManualWalkthrough: (value) => set({ manualWalkthrough: value }), }), { name: "superset-onboarding-v1" }, ), diff --git a/bun.lock b/bun.lock index 63d814f1cd9..3b6a4eb82ff 100644 --- a/bun.lock +++ b/bun.lock @@ -111,7 +111,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "1.8.4", + "version": "1.8.5", "dependencies": { "@ai-sdk/anthropic": "^3.0.43", "@ai-sdk/openai": "3.0.36",