Files
moreminimore-vibe/src/components/SetupBanner.tsx
Adeniji Adekunle James b1095b7951 feat: allow manual Node.js path configuration (#1577)
Add ability to manually configure Node.js path for users who have
Node.js
installed but not in their system PATH.

Features:
- Browse and select custom Node.js installation folder
- Visual status indicator showing Node.js version or "Not found"
- Reset to system default PATH option
- Manual configuration option in setup banner
- Real-time Node.js status checking

closes #1050

    
<!-- This is an auto-generated description by cubic. -->
---

## Summary by cubic
Adds manual Node.js path configuration so the app works even when Node
isn’t on PATH, fulfilling #1050. Users can browse to their install,
reset to default, and see real-time status in Settings and during setup.

- New Features
- Settings: NodePathSelector to browse a Node.js folder, show
version/“Not found” status, and reset to system PATH (persists
customNodePath).
- Setup banner: manual config flow with a folder picker if Node is
already installed.
- IPC: select-node-folder, set-node-path, get-node-path; reloads env and
prepends custom path to PATH.
- Real-time Node.js status check with visual indicator (CheckCircle on
valid).
  - E2E tests for browse, reset, and valid-status display.

<!-- End of auto-generated description by cubic. -->
2025-10-20 17:18:29 -07:00

489 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useNavigate } from "@tanstack/react-router";
import {
ChevronRight,
GiftIcon,
Sparkles,
CheckCircle,
AlertCircle,
XCircle,
Loader2,
Settings,
Folder,
} from "lucide-react";
import { providerSettingsRoute } from "@/routes/settings/providers/$provider";
import SetupProviderCard from "@/components/SetupProviderCard";
import { useState, useEffect, useCallback } from "react";
import { IpcClient } from "@/ipc/ipc_client";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { NodeSystemInfo } from "@/ipc/ipc_types";
import { usePostHog } from "posthog-js/react";
import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders";
import { useScrollAndNavigateTo } from "@/hooks/useScrollAndNavigateTo";
// @ts-ignore
import logo from "../../assets/logo.svg";
import { OnboardingBanner } from "./home/OnboardingBanner";
import { showError } from "@/lib/toast";
import { useSettings } from "@/hooks/useSettings";
type NodeInstallStep =
| "install"
| "waiting-for-continue"
| "continue-processing"
| "finished-checking";
export function SetupBanner() {
const posthog = usePostHog();
const navigate = useNavigate();
const [isOnboardingVisible, setIsOnboardingVisible] = useState(true);
const { isAnyProviderSetup, isLoading: loading } =
useLanguageModelProviders();
const [nodeSystemInfo, setNodeSystemInfo] = useState<NodeSystemInfo | null>(
null,
);
const [nodeCheckError, setNodeCheckError] = useState<boolean>(false);
const [nodeInstallStep, setNodeInstallStep] =
useState<NodeInstallStep>("install");
const checkNode = useCallback(async () => {
try {
setNodeCheckError(false);
const status = await IpcClient.getInstance().getNodejsStatus();
setNodeSystemInfo(status);
} catch (error) {
console.error("Failed to check Node.js status:", error);
setNodeSystemInfo(null);
setNodeCheckError(true);
}
}, [setNodeSystemInfo, setNodeCheckError]);
const [showManualConfig, setShowManualConfig] = useState(false);
const [isSelectingPath, setIsSelectingPath] = useState(false);
const { updateSettings } = useSettings();
// Add handler for manual path selection
const handleManualNodeConfig = useCallback(async () => {
setIsSelectingPath(true);
try {
const result = await IpcClient.getInstance().selectNodeFolder();
if (result.path) {
await updateSettings({ customNodePath: result.path });
await IpcClient.getInstance().reloadEnvPath();
await checkNode();
setNodeInstallStep("finished-checking");
setShowManualConfig(false);
} else if (result.path === null && result.canceled === false) {
showError(
`Could not find Node.js at the path "${result.selectedPath}"`,
);
}
} catch (error) {
showError("Error setting Node.js path:" + error);
} finally {
setIsSelectingPath(false);
}
}, [checkNode]);
useEffect(() => {
checkNode();
}, [checkNode]);
const settingsScrollAndNavigateTo = useScrollAndNavigateTo("/settings", {
behavior: "smooth",
block: "start",
});
const handleGoogleSetupClick = () => {
posthog.capture("setup-flow:ai-provider-setup:google:click");
navigate({
to: providerSettingsRoute.id,
params: { provider: "google" },
});
};
const handleOpenRouterSetupClick = () => {
posthog.capture("setup-flow:ai-provider-setup:openrouter:click");
navigate({
to: providerSettingsRoute.id,
params: { provider: "openrouter" },
});
};
const handleDyadProSetupClick = () => {
posthog.capture("setup-flow:ai-provider-setup:dyad:click");
IpcClient.getInstance().openExternalUrl(
"https://www.dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=setup-banner",
);
};
const handleOtherProvidersClick = () => {
posthog.capture("setup-flow:ai-provider-setup:other:click");
settingsScrollAndNavigateTo("provider-settings");
};
const handleNodeInstallClick = useCallback(async () => {
posthog.capture("setup-flow:start-node-install-click");
setNodeInstallStep("waiting-for-continue");
IpcClient.getInstance().openExternalUrl(nodeSystemInfo!.nodeDownloadUrl);
}, [nodeSystemInfo, setNodeInstallStep]);
const finishNodeInstall = useCallback(async () => {
posthog.capture("setup-flow:continue-node-install-click");
setNodeInstallStep("continue-processing");
await IpcClient.getInstance().reloadEnvPath();
await checkNode();
setNodeInstallStep("finished-checking");
}, [checkNode, setNodeInstallStep]);
// We only check for node version because pnpm is not required for the app to run.
const isNodeSetupComplete = Boolean(nodeSystemInfo?.nodeVersion);
const itemsNeedAction: string[] = [];
if (!isNodeSetupComplete && nodeSystemInfo) {
itemsNeedAction.push("node-setup");
}
if (!isAnyProviderSetup() && !loading) {
itemsNeedAction.push("ai-setup");
}
if (itemsNeedAction.length === 0) {
return (
<h1 className="text-center text-5xl font-bold mb-8 bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-gray-100 dark:to-gray-400 tracking-tight">
Build your dream app
</h1>
);
}
const bannerClasses = cn(
"w-full mb-6 border rounded-xl shadow-sm overflow-hidden",
"border-zinc-200 dark:border-zinc-700",
);
const getStatusIcon = (isComplete: boolean, hasError: boolean = false) => {
if (hasError) {
return <XCircle className="w-5 h-5 text-red-500" />;
}
return isComplete ? (
<CheckCircle className="w-5 h-5 text-green-600 dark:text-green-500" />
) : (
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-500" />
);
};
return (
<>
<p className="text-xl font-medium text-zinc-700 dark:text-zinc-300 p-4">
Setup Dyad
</p>
<OnboardingBanner
isVisible={isOnboardingVisible}
setIsVisible={setIsOnboardingVisible}
/>
<div className={bannerClasses}>
<Accordion
type="multiple"
className="w-full"
defaultValue={itemsNeedAction}
>
<AccordionItem
value="node-setup"
className={cn(
nodeCheckError
? "bg-red-50 dark:bg-red-900/30"
: isNodeSetupComplete
? "bg-green-50 dark:bg-green-900/30"
: "bg-yellow-50 dark:bg-yellow-900/30",
)}
>
<AccordionTrigger className="px-4 py-3 transition-colors w-full hover:no-underline">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
{getStatusIcon(isNodeSetupComplete, nodeCheckError)}
<span className="font-medium text-sm">
1. Install Node.js (App Runtime)
</span>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pt-2 pb-4 bg-white dark:bg-zinc-900 border-t border-inherit">
{nodeCheckError && (
<p className="text-sm text-red-600 dark:text-red-400">
Error checking Node.js status. Try installing Node.js.
</p>
)}
{isNodeSetupComplete ? (
<p className="text-sm">
Node.js ({nodeSystemInfo!.nodeVersion}) installed.{" "}
{nodeSystemInfo!.pnpmVersion && (
<span className="text-xs text-gray-500">
{" "}
(optional) pnpm ({nodeSystemInfo!.pnpmVersion}) installed.
</span>
)}
</p>
) : (
<div className="text-sm">
<p>Node.js is required to run apps locally.</p>
{nodeInstallStep === "waiting-for-continue" && (
<p className="mt-1">
After you have installed Node.js, click "Continue". If the
installer didn't work, try{" "}
<a
className="text-blue-500 dark:text-blue-400 hover:underline"
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://nodejs.org/en/download",
);
}}
>
more download options
</a>
.
</p>
)}
<NodeInstallButton
nodeInstallStep={nodeInstallStep}
handleNodeInstallClick={handleNodeInstallClick}
finishNodeInstall={finishNodeInstall}
/>
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => setShowManualConfig(!showManualConfig)}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
Node.js already installed? Configure path manually
</button>
{showManualConfig && (
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<Button
onClick={handleManualNodeConfig}
disabled={isSelectingPath}
variant="outline"
size="sm"
>
{isSelectingPath ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Selecting...
</>
) : (
<>
<Folder className="mr-2 h-4 w-4" />
Browse for Node.js folder
</>
)}
</Button>
</div>
)}
</div>
</div>
)}
<NodeJsHelpCallout />
</AccordionContent>
</AccordionItem>
<AccordionItem
value="ai-setup"
className={cn(
isAnyProviderSetup()
? "bg-green-50 dark:bg-green-900/30"
: "bg-yellow-50 dark:bg-yellow-900/30",
)}
>
<AccordionTrigger
className={cn(
"px-4 py-3 transition-colors w-full hover:no-underline",
)}
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
{getStatusIcon(isAnyProviderSetup())}
<span className="font-medium text-sm">
2. Setup AI Access
</span>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pt-2 pb-4 bg-white dark:bg-zinc-900 border-t border-inherit">
<p className="text-[15px] mb-3">
Not sure what to do? Watch the Get Started video above
</p>
<div className="flex gap-2">
<SetupProviderCard
className="flex-1"
variant="google"
onClick={handleGoogleSetupClick}
tabIndex={isNodeSetupComplete ? 0 : -1}
leadingIcon={
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400" />
}
title="Setup Google Gemini API Key"
chip={<>Free</>}
/>
<SetupProviderCard
className="flex-1"
variant="openrouter"
onClick={handleOpenRouterSetupClick}
tabIndex={isNodeSetupComplete ? 0 : -1}
leadingIcon={
<Sparkles className="w-4 h-4 text-teal-600 dark:text-teal-400" />
}
title="Setup OpenRouter API Key"
chip={<>Free</>}
/>
</div>
<SetupProviderCard
className="mt-2"
variant="dyad"
onClick={handleDyadProSetupClick}
tabIndex={isNodeSetupComplete ? 0 : -1}
leadingIcon={
<img src={logo} alt="Dyad Logo" className="w-6 h-6 mr-0.5" />
}
title="Setup Dyad Pro"
subtitle="Access all AI models with one plan"
chip={<>Recommended</>}
/>
<div
className="mt-2 p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800/70 transition-colors"
onClick={handleOtherProvidersClick}
role="button"
tabIndex={isNodeSetupComplete ? 0 : -1}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="bg-gray-100 dark:bg-gray-700 p-1.5 rounded-full">
<Settings className="w-4 h-4 text-gray-600 dark:text-gray-400" />
</div>
<div>
<h4 className="font-medium text-[15px] text-gray-800 dark:text-gray-300">
Setup other AI providers
</h4>
<p className="text-xs text-gray-600 dark:text-gray-400">
OpenAI, Anthropic and more
</p>
</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-600 dark:text-gray-400" />
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</>
);
}
function NodeJsHelpCallout() {
return (
<div className="mt-3 p-3 bg-(--background-lighter) border rounded-lg text-sm">
<p>
If you run into issues, read our{" "}
<a
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://www.dyad.sh/docs/help/nodejs",
);
}}
className="text-blue-600 dark:text-blue-400 hover:underline font-medium"
>
Node.js troubleshooting guide
</a>
.{" "}
</p>
<p className="mt-2">
Still stuck? Click the <b>Help</b> button in the bottom-left corner and
then <b>Report a Bug</b>.
</p>
</div>
);
}
function NodeInstallButton({
nodeInstallStep,
handleNodeInstallClick,
finishNodeInstall,
}: {
nodeInstallStep: NodeInstallStep;
handleNodeInstallClick: () => void;
finishNodeInstall: () => void;
}) {
switch (nodeInstallStep) {
case "install":
return (
<Button className="mt-3" onClick={handleNodeInstallClick}>
Install Node.js Runtime
</Button>
);
case "continue-processing":
return (
<Button className="mt-3" onClick={finishNodeInstall} disabled>
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Checking Node.js setup...
</div>
</Button>
);
case "waiting-for-continue":
return (
<Button className="mt-3" onClick={finishNodeInstall}>
<div className="flex items-center gap-2">
Continue | I installed Node.js
</div>
</Button>
);
case "finished-checking":
return (
<div className="mt-3 text-sm text-red-600 dark:text-red-400">
Node.js not detected. Closing and re-opening Dyad usually fixes this.
</div>
);
default:
const _exhaustiveCheck: never = nodeInstallStep;
}
}
export const OpenRouterSetupBanner = ({
className,
}: {
className?: string;
}) => {
const posthog = usePostHog();
const navigate = useNavigate();
return (
<SetupProviderCard
className={cn("mt-2", className)}
variant="openrouter"
onClick={() => {
posthog.capture("setup-flow:ai-provider-setup:openrouter:click");
navigate({
to: providerSettingsRoute.id,
params: { provider: "openrouter" },
});
}}
tabIndex={0}
leadingIcon={
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400" />
}
title="Setup OpenRouter API Key"
chip={
<>
<GiftIcon className="w-3 h-3" />
Free models available
</>
}
/>
);
};