feat(fake-llm-server): add initial setup for fake LLM server with TypeScript and Express

- Created package.json for dependencies and scripts
- Added tsconfig.json for TypeScript configuration
- Implemented fake stdio MCP server with basic calculator and environment variable printing tools
- Added shell script to run the fake stdio MCP server
- Updated root tsconfig.json for project references and path mapping
This commit is contained in:
Kunthawat Greethong
2025-12-19 09:36:31 +07:00
parent 07bf4414cc
commit 756b405423
412 changed files with 69158 additions and 8 deletions

View File

@@ -0,0 +1,150 @@
import { useNavigate } from "@tanstack/react-router";
import { PlusCircle, Search } from "lucide-react";
import { useAtom, useSetAtom } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
} from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useLoadApps } from "@/hooks/useLoadApps";
import { useMemo, useState } from "react";
import { AppSearchDialog } from "./AppSearchDialog";
import { useAddAppToFavorite } from "@/hooks/useAddAppToFavorite";
import { AppItem } from "./appItem";
export function AppList({ show }: { show?: boolean }) {
const navigate = useNavigate();
const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom);
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
const { apps, loading, error } = useLoadApps();
const { toggleFavorite, isLoading: isFavoriteLoading } =
useAddAppToFavorite();
// search dialog state
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
const allApps = useMemo(
() =>
apps.map((a) => ({
id: a.id,
name: a.name,
createdAt: a.createdAt,
matchedChatTitle: null,
matchedChatMessage: null,
})),
[apps],
);
const favoriteApps = useMemo(
() => apps.filter((app) => app.isFavorite),
[apps],
);
const nonFavoriteApps = useMemo(
() => apps.filter((app) => !app.isFavorite),
[apps],
);
if (!show) {
return null;
}
const handleAppClick = (id: number) => {
setSelectedAppId(id);
setSelectedChatId(null);
setIsSearchDialogOpen(false);
navigate({
to: "/",
search: { appId: id },
});
};
const handleNewApp = () => {
navigate({ to: "/" });
// We'll eventually need a create app workflow
};
const handleToggleFavorite = (appId: number, e: React.MouseEvent) => {
e.stopPropagation();
toggleFavorite(appId);
};
return (
<>
<SidebarGroup
className="overflow-y-auto h-[calc(100vh-112px)]"
data-testid="app-list-container"
>
<SidebarGroupLabel>Your Apps</SidebarGroupLabel>
<SidebarGroupContent>
<div className="flex flex-col space-y-2">
<Button
onClick={handleNewApp}
variant="outline"
className="flex items-center justify-start gap-2 mx-2 py-2"
>
<PlusCircle size={16} />
<span>New App</span>
</Button>
<Button
onClick={() => setIsSearchDialogOpen(!isSearchDialogOpen)}
variant="outline"
className="flex items-center justify-start gap-2 mx-2 py-3"
data-testid="search-apps-button"
>
<Search size={16} />
<span>Search Apps</span>
</Button>
{loading ? (
<div className="py-2 px-4 text-sm text-gray-500">
Loading apps...
</div>
) : error ? (
<div className="py-2 px-4 text-sm text-red-500">
Error loading apps
</div>
) : apps.length === 0 ? (
<div className="py-2 px-4 text-sm text-gray-500">
No apps found
</div>
) : (
<SidebarMenu className="space-y-1" data-testid="app-list">
<SidebarGroupLabel>Favorite apps</SidebarGroupLabel>
{favoriteApps.map((app) => (
<AppItem
key={app.id}
app={app}
handleAppClick={handleAppClick}
selectedAppId={selectedAppId}
handleToggleFavorite={handleToggleFavorite}
isFavoriteLoading={isFavoriteLoading}
/>
))}
<SidebarGroupLabel>Other apps</SidebarGroupLabel>
{nonFavoriteApps.map((app) => (
<AppItem
key={app.id}
app={app}
handleAppClick={handleAppClick}
selectedAppId={selectedAppId}
handleToggleFavorite={handleToggleFavorite}
isFavoriteLoading={isFavoriteLoading}
/>
))}
</SidebarMenu>
)}
</div>
</SidebarGroupContent>
</SidebarGroup>
<AppSearchDialog
open={isSearchDialogOpen}
onOpenChange={setIsSearchDialogOpen}
onSelectApp={handleAppClick}
allApps={allApps}
/>
</>
);
}

View File

@@ -0,0 +1,153 @@
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from "./ui/command";
import { useState, useEffect } from "react";
import { useSearchApps } from "@/hooks/useSearchApps";
import type { AppSearchResult } from "@/lib/schemas";
type AppSearchDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelectApp: (appId: number) => void;
allApps: AppSearchResult[];
};
export function AppSearchDialog({
open,
onOpenChange,
onSelectApp,
allApps,
}: AppSearchDialogProps) {
const [searchQuery, setSearchQuery] = useState<string>("");
function useDebouncedValue<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState<T>(value);
useEffect(() => {
const handle = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(handle);
}, [value, delay]);
return debounced;
}
const debouncedQuery = useDebouncedValue(searchQuery, 150);
const { apps: searchResults } = useSearchApps(debouncedQuery);
// Show all apps if search is empty, otherwise show search results
const appsToShow: AppSearchResult[] =
debouncedQuery.trim() === "" ? allApps : searchResults;
const commandFilter = (
value: string,
search: string,
keywords?: string[],
): number => {
const q = search.trim().toLowerCase();
if (!q) return 1;
const v = (value || "").toLowerCase();
if (v.includes(q)) {
// Higher score for earlier match in title/value
return 100 - Math.max(0, v.indexOf(q));
}
const foundInKeywords = (keywords || []).some((k) =>
(k || "").toLowerCase().includes(q),
);
return foundInKeywords ? 50 : 0;
};
function getSnippet(
text: string,
query: string,
radius = 50,
): {
before: string;
match: string;
after: string;
raw: string;
} {
const q = query.trim();
const lowerText = text.toLowerCase();
const lowerQuery = q.toLowerCase();
const idx = lowerText.indexOf(lowerQuery);
if (idx === -1) {
const raw =
text.length > radius * 2 ? text.slice(0, radius * 2) + "…" : text;
return { before: "", match: "", after: "", raw };
}
const start = Math.max(0, idx - radius);
const end = Math.min(text.length, idx + q.length + radius);
const before = (start > 0 ? "…" : "") + text.slice(start, idx);
const match = text.slice(idx, idx + q.length);
const after =
text.slice(idx + q.length, end) + (end < text.length ? "…" : "");
return { before, match, after, raw: before + match + after };
}
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onOpenChange(!open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, [open, onOpenChange]);
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
data-testid="app-search-dialog"
filter={commandFilter}
>
<CommandInput
placeholder="Search apps"
value={searchQuery}
onValueChange={setSearchQuery}
data-testid="app-search-input"
/>
<CommandList data-testid="app-search-list">
<CommandEmpty data-testid="app-search-empty">
No results found.
</CommandEmpty>
<CommandGroup heading="Apps" data-testid="app-search-group">
{appsToShow.map((app) => {
const isSearch = searchQuery.trim() !== "";
let snippet = null;
if (isSearch && app.matchedChatMessage) {
snippet = getSnippet(app.matchedChatMessage, searchQuery);
} else if (isSearch && app.matchedChatTitle) {
snippet = getSnippet(app.matchedChatTitle, searchQuery);
}
return (
<CommandItem
key={app.id}
onSelect={() => onSelectApp(app.id)}
value={app.name + (snippet ? ` ${snippet.raw}` : "")}
keywords={snippet ? [snippet.raw] : []}
data-testid={`app-search-item-${app.id}`}
>
<div className="flex flex-col">
<span>{app.name}</span>
{snippet && (
<span className="text-xs text-muted-foreground mt-1 line-clamp-2">
{snippet.before}
<mark className="bg-transparent underline decoration-2 decoration-primary">
{snippet.match}
</mark>
{snippet.after}
</span>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</CommandDialog>
);
}

View File

@@ -0,0 +1,157 @@
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Terminal } from "lucide-react";
import { IpcClient } from "@/ipc/ipc_client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AppUpgrade } from "@/ipc/ipc_types";
export function AppUpgrades({ appId }: { appId: number | null }) {
const queryClient = useQueryClient();
const {
data: upgrades,
isLoading,
error: queryError,
} = useQuery({
queryKey: ["app-upgrades", appId],
queryFn: () => {
if (!appId) {
return Promise.resolve([]);
}
return IpcClient.getInstance().getAppUpgrades({ appId });
},
enabled: !!appId,
});
const {
mutate: executeUpgrade,
isPending: isUpgrading,
error: mutationError,
variables: upgradingVariables,
} = useMutation({
mutationFn: (upgradeId: string) => {
if (!appId) {
throw new Error("appId is not set");
}
return IpcClient.getInstance().executeAppUpgrade({
appId,
upgradeId,
});
},
onSuccess: (_, upgradeId) => {
queryClient.invalidateQueries({ queryKey: ["app-upgrades", appId] });
if (upgradeId === "capacitor") {
// Capacitor upgrade is done, so we need to invalidate the Capacitor
// query to show the new status.
queryClient.invalidateQueries({ queryKey: ["is-capacitor", appId] });
}
},
});
const handleUpgrade = (upgradeId: string) => {
executeUpgrade(upgradeId);
};
if (!appId) {
return null;
}
if (isLoading) {
return (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-3 text-gray-900 dark:text-gray-100">
App Upgrades
</h3>
<Loader2 className="h-6 w-6 animate-spin" />
</div>
);
}
if (queryError) {
return (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-3 text-gray-900 dark:text-gray-100">
App Upgrades
</h3>
<Alert variant="destructive">
<AlertTitle>Error loading upgrades</AlertTitle>
<AlertDescription>{queryError.message}</AlertDescription>
</Alert>
</div>
);
}
const currentUpgrades = upgrades?.filter((u) => u.isNeeded) ?? [];
return (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-3 text-gray-900 dark:text-gray-100">
App Upgrades
</h3>
{currentUpgrades.length === 0 ? (
<div
data-testid="no-app-upgrades-needed"
className="p-4 bg-green-50 border border-green-200 dark:bg-green-900/20 dark:border-green-800/50 rounded-lg text-sm text-green-800 dark:text-green-300"
>
App is up-to-date and has all Dyad capabilities enabled
</div>
) : (
<div className="space-y-4">
{currentUpgrades.map((upgrade: AppUpgrade) => (
<div
key={upgrade.id}
className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg flex justify-between items-start"
>
<div className="flex-grow">
<h4 className="font-semibold text-gray-800 dark:text-gray-200">
{upgrade.title}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{upgrade.description}
</p>
{mutationError && upgradingVariables === upgrade.id && (
<Alert
variant="destructive"
className="mt-3 dark:bg-destructive/15"
>
<Terminal className="h-4 w-4" />
<AlertTitle className="dark:text-red-200">
Upgrade Failed
</AlertTitle>
<AlertDescription className="text-xs text-red-400 dark:text-red-300">
{(mutationError as Error).message}{" "}
<a
onClick={(e) => {
e.stopPropagation();
IpcClient.getInstance().openExternalUrl(
upgrade.manualUpgradeUrl ?? "https://dyad.sh/docs",
);
}}
className="underline font-medium hover:dark:text-red-200"
>
Manual Upgrade Instructions
</a>
</AlertDescription>
</Alert>
)}
</div>
<Button
onClick={() => handleUpgrade(upgrade.id)}
disabled={isUpgrading && upgradingVariables === upgrade.id}
className="ml-4 flex-shrink-0"
size="sm"
data-testid={`app-upgrade-${upgrade.id}`}
>
{isUpgrading && upgradingVariables === upgrade.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Upgrade
</Button>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { useSettings } from "@/hooks/useSettings";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { showInfo } from "@/lib/toast";
export function AutoApproveSwitch({
showToast = true,
}: {
showToast?: boolean;
}) {
const { settings, updateSettings } = useSettings();
return (
<div className="flex items-center space-x-2">
<Switch
id="auto-approve"
checked={settings?.autoApproveChanges}
onCheckedChange={() => {
updateSettings({ autoApproveChanges: !settings?.autoApproveChanges });
if (!settings?.autoApproveChanges && showToast) {
showInfo("You can disable auto-approve in the Settings.");
}
}}
/>
<Label htmlFor="auto-approve">Auto-approve</Label>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { useSettings } from "@/hooks/useSettings";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { showInfo } from "@/lib/toast";
export function AutoFixProblemsSwitch({
showToast = false,
}: {
showToast?: boolean;
}) {
const { settings, updateSettings } = useSettings();
return (
<div className="flex items-center space-x-2">
<Switch
id="auto-fix-problems"
checked={settings?.enableAutoFixProblems}
onCheckedChange={() => {
updateSettings({
enableAutoFixProblems: !settings?.enableAutoFixProblems,
});
if (!settings?.enableAutoFixProblems && showToast) {
showInfo("You can disable Auto-fix problems in the Settings page.");
}
}}
/>
<Label htmlFor="auto-fix-problems">Auto-fix problems</Label>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { useSettings } from "@/hooks/useSettings";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { toast } from "sonner";
import { IpcClient } from "@/ipc/ipc_client";
export function AutoUpdateSwitch() {
const { settings, updateSettings } = useSettings();
if (!settings) {
return null;
}
return (
<div className="flex items-center space-x-2">
<Switch
id="enable-auto-update"
checked={settings.enableAutoUpdate}
onCheckedChange={(checked) => {
updateSettings({ enableAutoUpdate: checked });
toast("Auto-update settings changed", {
description:
"You will need to restart Dyad for your settings to take effect.",
action: {
label: "Restart Dyad",
onClick: () => {
IpcClient.getInstance().restartDyad();
},
},
});
}}
/>
<Label htmlFor="enable-auto-update">Auto-update</Label>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { IpcClient } from "@/ipc/ipc_client";
import { Dialog, DialogTitle } from "@radix-ui/react-dialog";
import { DialogContent, DialogHeader } from "./ui/dialog";
import { Button } from "./ui/button";
import { BugIcon, Camera } from "lucide-react";
import { useState } from "react";
import { ScreenshotSuccessDialog } from "./ScreenshotSuccessDialog";
interface BugScreenshotDialogProps {
isOpen: boolean;
onClose: () => void;
handleReportBug: () => Promise<void>;
isLoading: boolean;
}
export function BugScreenshotDialog({
isOpen,
onClose,
handleReportBug,
isLoading,
}: BugScreenshotDialogProps) {
const [isScreenshotSuccessOpen, setIsScreenshotSuccessOpen] = useState(false);
const [screenshotError, setScreenshotError] = useState<string | null>(null);
const handleReportBugWithScreenshot = async () => {
setScreenshotError(null);
onClose();
setTimeout(async () => {
try {
await IpcClient.getInstance().takeScreenshot();
setIsScreenshotSuccessOpen(true);
} catch (error) {
setScreenshotError(
error instanceof Error ? error.message : "Failed to take screenshot",
);
}
}, 200); // Small delay for dialog to close
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Take a screenshot?</DialogTitle>
</DialogHeader>
<div className="flex flex-col space-y-4 w-full">
<div className="flex flex-col space-y-2">
<Button
variant="default"
onClick={handleReportBugWithScreenshot}
className="w-full py-6 border-primary/50 shadow-sm shadow-primary/10 transition-all hover:shadow-md hover:shadow-primary/15"
>
<Camera className="mr-2 h-5 w-5" /> Take a screenshot
(recommended)
</Button>
<p className="text-sm text-muted-foreground px-2">
You'll get better and faster responses if you do this!
</p>
</div>
<div className="flex flex-col space-y-2">
<Button
variant="outline"
onClick={() => {
handleReportBug();
}}
className="w-full py-6 bg-(--background-lightest)"
>
<BugIcon className="mr-2 h-5 w-5" />{" "}
{isLoading
? "Preparing Report..."
: "File bug report without screenshot"}
</Button>
<p className="text-sm text-muted-foreground px-2">
We'll still try to respond but might not be able to help as much.
</p>
</div>
{screenshotError && (
<p className="text-sm text-destructive px-2">
Failed to take screenshot: {screenshotError}
</p>
)}
</div>
</DialogContent>
<ScreenshotSuccessDialog
isOpen={isScreenshotSuccessOpen}
onClose={() => setIsScreenshotSuccessOpen(false)}
handleReportBug={handleReportBug}
isLoading={isLoading}
/>
</Dialog>
);
}

View File

@@ -0,0 +1,258 @@
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { IpcClient } from "@/ipc/ipc_client";
import { showSuccess } from "@/lib/toast";
import {
Smartphone,
TabletSmartphone,
Loader2,
ExternalLink,
Copy,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface CapacitorControlsProps {
appId: number;
}
type CapacitorStatus = "idle" | "syncing" | "opening";
export function CapacitorControls({ appId }: CapacitorControlsProps) {
const [errorDialogOpen, setErrorDialogOpen] = useState(false);
const [errorDetails, setErrorDetails] = useState<{
title: string;
message: string;
} | null>(null);
const [iosStatus, setIosStatus] = useState<CapacitorStatus>("idle");
const [androidStatus, setAndroidStatus] = useState<CapacitorStatus>("idle");
// Check if Capacitor is installed
const { data: isCapacitor, isLoading } = useQuery({
queryKey: ["is-capacitor", appId],
queryFn: () => IpcClient.getInstance().isCapacitor({ appId }),
enabled: appId !== undefined && appId !== null,
});
const showErrorDialog = (title: string, error: unknown) => {
const errorMessage = error instanceof Error ? error.message : String(error);
setErrorDetails({ title, message: errorMessage });
setErrorDialogOpen(true);
};
// Sync and open iOS mutation
const syncAndOpenIosMutation = useMutation({
mutationFn: async () => {
setIosStatus("syncing");
// First sync
await IpcClient.getInstance().syncCapacitor({ appId });
setIosStatus("opening");
// Then open iOS
await IpcClient.getInstance().openIos({ appId });
},
onSuccess: () => {
setIosStatus("idle");
showSuccess("Synced and opened iOS project in Xcode");
},
onError: (error) => {
setIosStatus("idle");
showErrorDialog("Failed to sync and open iOS project", error);
},
});
// Sync and open Android mutation
const syncAndOpenAndroidMutation = useMutation({
mutationFn: async () => {
setAndroidStatus("syncing");
// First sync
await IpcClient.getInstance().syncCapacitor({ appId });
setAndroidStatus("opening");
// Then open Android
await IpcClient.getInstance().openAndroid({ appId });
},
onSuccess: () => {
setAndroidStatus("idle");
showSuccess("Synced and opened Android project in Android Studio");
},
onError: (error) => {
setAndroidStatus("idle");
showErrorDialog("Failed to sync and open Android project", error);
},
});
// Helper function to get button text based on status
const getIosButtonText = () => {
switch (iosStatus) {
case "syncing":
return { main: "Syncing...", sub: "Building app" };
case "opening":
return { main: "Opening...", sub: "Launching Xcode" };
default:
return { main: "Sync & Open iOS", sub: "Xcode" };
}
};
const getAndroidButtonText = () => {
switch (androidStatus) {
case "syncing":
return { main: "Syncing...", sub: "Building app" };
case "opening":
return { main: "Opening...", sub: "Launching Android Studio" };
default:
return { main: "Sync & Open Android", sub: "Android Studio" };
}
};
// Don't render anything if loading or if Capacitor is not installed
if (isLoading || !isCapacitor) {
return null;
}
const iosButtonText = getIosButtonText();
const androidButtonText = getAndroidButtonText();
return (
<>
<Card className="mt-1" data-testid="capacitor-controls">
<CardHeader>
<CardTitle className="flex items-center justify-between">
Mobile Development
<Button
variant="ghost"
size="sm"
onClick={() => {
// TODO: Add actual help link
IpcClient.getInstance().openExternalUrl(
"https://dyad.sh/docs/guides/mobile-app#troubleshooting",
);
}}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 flex items-center gap-1"
>
Need help?
<ExternalLink className="h-3 w-3" />
</Button>
</CardTitle>
<CardDescription>
Sync and open your Capacitor mobile projects
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-2">
<Button
onClick={() => syncAndOpenIosMutation.mutate()}
disabled={syncAndOpenIosMutation.isPending}
variant="outline"
size="sm"
className="flex items-center gap-2 h-10"
>
{syncAndOpenIosMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Smartphone className="h-4 w-4" />
)}
<div className="text-left">
<div className="text-xs font-medium">{iosButtonText.main}</div>
<div className="text-xs text-gray-500">{iosButtonText.sub}</div>
</div>
</Button>
<Button
onClick={() => syncAndOpenAndroidMutation.mutate()}
disabled={syncAndOpenAndroidMutation.isPending}
variant="outline"
size="sm"
className="flex items-center gap-2 h-10"
>
{syncAndOpenAndroidMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TabletSmartphone className="h-4 w-4" />
)}
<div className="text-left">
<div className="text-xs font-medium">
{androidButtonText.main}
</div>
<div className="text-xs text-gray-500">
{androidButtonText.sub}
</div>
</div>
</Button>
</div>
</CardContent>
</Card>
{/* Error Dialog */}
<Dialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="text-red-600 dark:text-red-400">
{errorDetails?.title}
</DialogTitle>
<DialogDescription>
An error occurred while running the Capacitor command. See details
below:
</DialogDescription>
</DialogHeader>
{errorDetails && (
<div className="relative">
<div className="max-h-[50vh] w-full max-w-md rounded border p-4 bg-gray-50 dark:bg-gray-900 overflow-y-auto">
<pre className="text-xs whitespace-pre-wrap font-mono">
{errorDetails.message}
</pre>
</div>
<Button
onClick={() => {
navigator.clipboard.writeText(errorDetails.message);
showSuccess("Error details copied to clipboard");
}}
variant="ghost"
size="sm"
className="absolute top-2 right-2 h-8 w-8 p-0"
>
<Copy className="h-4 w-4" />
</Button>
</div>
)}
<div className="flex justify-end gap-2">
<Button
onClick={() => {
if (errorDetails) {
navigator.clipboard.writeText(errorDetails.message);
showSuccess("Error details copied to clipboard");
}
}}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<Copy className="h-4 w-4" />
Copy Error
</Button>
<Button
onClick={() => setErrorDialogOpen(false)}
variant="outline"
size="sm"
>
Close
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,37 @@
import { ContextFilesPicker } from "./ContextFilesPicker";
import { ModelPicker } from "./ModelPicker";
import { ProModeSelector } from "./ProModeSelector";
import { ChatModeSelector } from "./ChatModeSelector";
import { McpToolsPicker } from "@/components/McpToolsPicker";
import { useSettings } from "@/hooks/useSettings";
export function ChatInputControls({
showContextFilesPicker = false,
}: {
showContextFilesPicker?: boolean;
}) {
const { settings } = useSettings();
return (
<div className="flex">
<ChatModeSelector />
{settings?.selectedChatMode === "agent" && (
<>
<div className="w-1.5"></div>
<McpToolsPicker />
</>
)}
<div className="w-1.5"></div>
<ModelPicker />
<div className="w-1.5"></div>
<ProModeSelector />
<div className="w-1"></div>
{showContextFilesPicker && (
<>
<ContextFilesPicker />
<div className="w-0.5"></div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,303 @@
import { useEffect, useState } from "react";
import { useNavigate, useRouterState } from "@tanstack/react-router";
import { formatDistanceToNow } from "date-fns";
import { PlusCircle, MoreVertical, Trash2, Edit3, Search } from "lucide-react";
import { useAtom } from "jotai";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { dropdownOpenAtom } from "@/atoms/uiAtoms";
import { IpcClient } from "@/ipc/ipc_client";
import { showError, showSuccess } from "@/lib/toast";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useChats } from "@/hooks/useChats";
import { RenameChatDialog } from "@/components/chat/RenameChatDialog";
import { DeleteChatDialog } from "@/components/chat/DeleteChatDialog";
import { ChatSearchDialog } from "./ChatSearchDialog";
import { useSelectChat } from "@/hooks/useSelectChat";
export function ChatList({ show }: { show?: boolean }) {
const navigate = useNavigate();
const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom);
const [selectedAppId] = useAtom(selectedAppIdAtom);
const [, setIsDropdownOpen] = useAtom(dropdownOpenAtom);
const { chats, loading, refreshChats } = useChats(selectedAppId);
const routerState = useRouterState();
const isChatRoute = routerState.location.pathname === "/chat";
// Rename dialog state
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [renameChatId, setRenameChatId] = useState<number | null>(null);
const [renameChatTitle, setRenameChatTitle] = useState("");
// Delete dialog state
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deleteChatId, setDeleteChatId] = useState<number | null>(null);
const [deleteChatTitle, setDeleteChatTitle] = useState("");
// search dialog state
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
const { selectChat } = useSelectChat();
// Update selectedChatId when route changes
useEffect(() => {
if (isChatRoute) {
const id = routerState.location.search.id;
if (id) {
console.log("Setting selected chat id to", id);
setSelectedChatId(id);
}
}
}, [isChatRoute, routerState.location.search, setSelectedChatId]);
if (!show) {
return;
}
const handleChatClick = ({
chatId,
appId,
}: {
chatId: number;
appId: number;
}) => {
selectChat({ chatId, appId });
setIsSearchDialogOpen(false);
};
const handleNewChat = async () => {
// Only create a new chat if an app is selected
if (selectedAppId) {
try {
// Create a new chat with an empty title for now
const chatId = await IpcClient.getInstance().createChat(selectedAppId);
// Navigate to the new chat
setSelectedChatId(chatId);
navigate({
to: "/chat",
search: { id: chatId },
});
// Refresh the chat list
await refreshChats();
} catch (error) {
// DO A TOAST
showError(`Failed to create new chat: ${(error as any).toString()}`);
}
} else {
// If no app is selected, navigate to home page
navigate({ to: "/" });
}
};
const handleDeleteChat = async (chatId: number) => {
try {
await IpcClient.getInstance().deleteChat(chatId);
showSuccess("Chat deleted successfully");
// If the deleted chat was selected, navigate to home
if (selectedChatId === chatId) {
setSelectedChatId(null);
navigate({ to: "/chat" });
}
// Refresh the chat list
await refreshChats();
} catch (error) {
showError(`Failed to delete chat: ${(error as any).toString()}`);
}
};
const handleDeleteChatClick = (chatId: number, chatTitle: string) => {
setDeleteChatId(chatId);
setDeleteChatTitle(chatTitle);
setIsDeleteDialogOpen(true);
};
const handleConfirmDelete = async () => {
if (deleteChatId !== null) {
await handleDeleteChat(deleteChatId);
setIsDeleteDialogOpen(false);
setDeleteChatId(null);
setDeleteChatTitle("");
}
};
const handleRenameChat = (chatId: number, currentTitle: string) => {
setRenameChatId(chatId);
setRenameChatTitle(currentTitle);
setIsRenameDialogOpen(true);
};
const handleRenameDialogClose = (open: boolean) => {
setIsRenameDialogOpen(open);
if (!open) {
setRenameChatId(null);
setRenameChatTitle("");
}
};
return (
<>
<SidebarGroup
className="overflow-y-auto h-[calc(100vh-112px)]"
data-testid="chat-list-container"
>
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel>
<SidebarGroupContent>
<div className="flex flex-col space-y-4">
<Button
onClick={handleNewChat}
variant="outline"
className="flex items-center justify-start gap-2 mx-2 py-3"
>
<PlusCircle size={16} />
<span>New Chat</span>
</Button>
<Button
onClick={() => setIsSearchDialogOpen(!isSearchDialogOpen)}
variant="outline"
className="flex items-center justify-start gap-2 mx-2 py-3"
data-testid="search-chats-button"
>
<Search size={16} />
<span>Search chats</span>
</Button>
{loading ? (
<div className="py-3 px-4 text-sm text-gray-500">
Loading chats...
</div>
) : chats.length === 0 ? (
<div className="py-3 px-4 text-sm text-gray-500">
No chats found
</div>
) : (
<SidebarMenu className="space-y-1">
{chats.map((chat) => (
<SidebarMenuItem key={chat.id} className="mb-1">
<div className="flex w-[175px] items-center">
<Button
variant="ghost"
onClick={() =>
handleChatClick({
chatId: chat.id,
appId: chat.appId,
})
}
className={`justify-start w-full text-left py-3 pr-1 hover:bg-sidebar-accent/80 ${
selectedChatId === chat.id
? "bg-sidebar-accent text-sidebar-accent-foreground"
: ""
}`}
>
<div className="flex flex-col w-full">
<span className="truncate">
{chat.title || "New Chat"}
</span>
<span className="text-xs text-gray-500">
{formatDistanceToNow(new Date(chat.createdAt), {
addSuffix: true,
})}
</span>
</div>
</Button>
{selectedChatId === chat.id && (
<DropdownMenu
modal={false}
onOpenChange={(open) => setIsDropdownOpen(open)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="ml-1 w-4"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="space-y-1 p-2"
>
<DropdownMenuItem
onClick={() =>
handleRenameChat(chat.id, chat.title || "")
}
className="px-3 py-2"
>
<Edit3 className="mr-2 h-4 w-4" />
<span>Rename Chat</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
handleDeleteChatClick(
chat.id,
chat.title || "New Chat",
)
}
className="px-3 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/50 focus:bg-red-50 dark:focus:bg-red-950/50"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete Chat</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</SidebarMenuItem>
))}
</SidebarMenu>
)}
</div>
</SidebarGroupContent>
</SidebarGroup>
{/* Rename Chat Dialog */}
{renameChatId !== null && (
<RenameChatDialog
chatId={renameChatId}
currentTitle={renameChatTitle}
isOpen={isRenameDialogOpen}
onOpenChange={handleRenameDialogClose}
onRename={refreshChats}
/>
)}
{/* Delete Chat Dialog */}
<DeleteChatDialog
isOpen={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirmDelete={handleConfirmDelete}
chatTitle={deleteChatTitle}
/>
{/* Chat Search Dialog */}
<ChatSearchDialog
open={isSearchDialogOpen}
onOpenChange={setIsSearchDialogOpen}
onSelectChat={handleChatClick}
appId={selectedAppId}
allChats={chats}
/>
</>
);
}

View File

@@ -0,0 +1,95 @@
import {
MiniSelectTrigger,
Select,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useSettings } from "@/hooks/useSettings";
import type { ChatMode } from "@/lib/schemas";
import { cn } from "@/lib/utils";
import { detectIsMac } from "@/hooks/useChatModeToggle";
export function ChatModeSelector() {
const { settings, updateSettings } = useSettings();
const selectedMode = settings?.selectedChatMode || "build";
const handleModeChange = (value: string) => {
updateSettings({ selectedChatMode: value as ChatMode });
};
const getModeDisplayName = (mode: ChatMode) => {
switch (mode) {
case "build":
return "Build";
case "ask":
return "Ask";
case "agent":
return "Build (MCP)";
default:
return "Build";
}
};
const isMac = detectIsMac();
return (
<Select value={selectedMode} onValueChange={handleModeChange}>
<Tooltip>
<TooltipTrigger asChild>
<MiniSelectTrigger
data-testid="chat-mode-selector"
className={cn(
"h-6 w-fit px-1.5 py-0 text-xs-sm font-medium shadow-none gap-0.5",
selectedMode === "build"
? "bg-background hover:bg-muted/50 focus:bg-muted/50"
: "bg-primary/10 hover:bg-primary/20 focus:bg-primary/20 text-primary border-primary/20 dark:bg-primary/20 dark:hover:bg-primary/30 dark:focus:bg-primary/30",
)}
size="sm"
>
<SelectValue>{getModeDisplayName(selectedMode)}</SelectValue>
</MiniSelectTrigger>
</TooltipTrigger>
<TooltipContent>
<div className="flex flex-col">
<span>Open mode menu</span>
<span className="text-xs text-gray-200 dark:text-gray-500">
{isMac ? "⌘ + ." : "Ctrl + ."} to toggle
</span>
</div>
</TooltipContent>
</Tooltip>
<SelectContent align="start" onCloseAutoFocus={(e) => e.preventDefault()}>
<SelectItem value="build">
<div className="flex flex-col items-start">
<span className="font-medium">Build</span>
<span className="text-xs text-muted-foreground">
Generate and edit code
</span>
</div>
</SelectItem>
<SelectItem value="ask">
<div className="flex flex-col items-start">
<span className="font-medium">Ask</span>
<span className="text-xs text-muted-foreground">
Ask questions about the app
</span>
</div>
</SelectItem>
<SelectItem value="agent">
<div className="flex flex-col items-start">
<span className="font-medium">Build with MCP (experimental)</span>
<span className="text-xs text-muted-foreground">
Like Build, but can use tools (MCP) to generate code
</span>
</div>
</SelectItem>
</SelectContent>
</Select>
);
}

View File

@@ -0,0 +1,204 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { useAtomValue, useSetAtom } from "jotai";
import {
chatMessagesByIdAtom,
chatStreamCountByIdAtom,
isStreamingByIdAtom,
} from "../atoms/chatAtoms";
import { IpcClient } from "@/ipc/ipc_client";
import { ChatHeader } from "./chat/ChatHeader";
import { MessagesList } from "./chat/MessagesList";
import { ChatInput } from "./chat/ChatInput";
import { VersionPane } from "./chat/VersionPane";
import { ChatError } from "./chat/ChatError";
import { Button } from "@/components/ui/button";
import { ArrowDown } from "lucide-react";
interface ChatPanelProps {
chatId?: number;
isPreviewOpen: boolean;
onTogglePreview: () => void;
}
export function ChatPanel({
chatId,
isPreviewOpen,
onTogglePreview,
}: ChatPanelProps) {
const messagesById = useAtomValue(chatMessagesByIdAtom);
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
const [isVersionPaneOpen, setIsVersionPaneOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const streamCountById = useAtomValue(chatStreamCountByIdAtom);
const isStreamingById = useAtomValue(isStreamingByIdAtom);
// Reference to store the processed prompt so we don't submit it twice
const messagesEndRef = useRef<HTMLDivElement | null>(null);
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
// Scroll-related properties
const [isUserScrolling, setIsUserScrolling] = useState(false);
const [showScrollButton, setShowScrollButton] = useState(false);
const userScrollTimeoutRef = useRef<number | null>(null);
const lastScrollTopRef = useRef<number>(0);
const scrollToBottom = (behavior: ScrollBehavior = "smooth") => {
messagesEndRef.current?.scrollIntoView({ behavior });
};
const handleScrollButtonClick = () => {
if (!messagesContainerRef.current) return;
scrollToBottom("smooth");
};
const getDistanceFromBottom = () => {
if (!messagesContainerRef.current) return 0;
const container = messagesContainerRef.current;
return (
container.scrollHeight - (container.scrollTop + container.clientHeight)
);
};
const isNearBottom = (threshold: number = 100) => {
return getDistanceFromBottom() <= threshold;
};
const scrollAwayThreshold = 150; // pixels from bottom to consider "scrolled away"
const handleScroll = useCallback(() => {
if (!messagesContainerRef.current) return;
const container = messagesContainerRef.current;
const distanceFromBottom =
container.scrollHeight - (container.scrollTop + container.clientHeight);
// User has scrolled away from bottom
if (distanceFromBottom > scrollAwayThreshold) {
setIsUserScrolling(true);
setShowScrollButton(true);
if (userScrollTimeoutRef.current) {
window.clearTimeout(userScrollTimeoutRef.current);
}
userScrollTimeoutRef.current = window.setTimeout(() => {
setIsUserScrolling(false);
}, 2000); // Increased timeout to 2 seconds
} else {
// User is near bottom
setIsUserScrolling(false);
setShowScrollButton(false);
}
lastScrollTopRef.current = container.scrollTop;
}, []);
useEffect(() => {
const streamCount = chatId ? (streamCountById.get(chatId) ?? 0) : 0;
console.log("streamCount - scrolling to bottom", streamCount);
scrollToBottom();
}, [
chatId,
chatId ? (streamCountById.get(chatId) ?? 0) : 0,
chatId ? (isStreamingById.get(chatId) ?? false) : false,
]);
useEffect(() => {
const container = messagesContainerRef.current;
if (container) {
container.addEventListener("scroll", handleScroll, { passive: true });
}
return () => {
if (container) {
container.removeEventListener("scroll", handleScroll);
}
if (userScrollTimeoutRef.current) {
window.clearTimeout(userScrollTimeoutRef.current);
}
};
}, [handleScroll]);
const fetchChatMessages = useCallback(async () => {
if (!chatId) {
// no-op when no chat
return;
}
const chat = await IpcClient.getInstance().getChat(chatId);
setMessagesById((prev) => {
const next = new Map(prev);
next.set(chatId, chat.messages);
return next;
});
}, [chatId, setMessagesById]);
useEffect(() => {
fetchChatMessages();
}, [fetchChatMessages]);
const messages = chatId ? (messagesById.get(chatId) ?? []) : [];
const isStreaming = chatId ? (isStreamingById.get(chatId) ?? false) : false;
// Auto-scroll effect when messages change during streaming
useEffect(() => {
if (
!isUserScrolling &&
isStreaming &&
messagesContainerRef.current &&
messages.length > 0
) {
// Only auto-scroll if user is close to bottom
if (isNearBottom(280)) {
requestAnimationFrame(() => {
scrollToBottom("instant");
});
}
}
}, [messages, isUserScrolling, isStreaming]);
return (
<div className="flex flex-col h-full">
<ChatHeader
isVersionPaneOpen={isVersionPaneOpen}
isPreviewOpen={isPreviewOpen}
onTogglePreview={onTogglePreview}
onVersionClick={() => setIsVersionPaneOpen(!isVersionPaneOpen)}
/>
<div className="flex flex-1 overflow-hidden">
{!isVersionPaneOpen && (
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 relative overflow-hidden">
<MessagesList
messages={messages}
messagesEndRef={messagesEndRef}
ref={messagesContainerRef}
/>
{/* Scroll to bottom button */}
{showScrollButton && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-10">
<Button
onClick={handleScrollButtonClick}
size="icon"
className="rounded-full shadow-lg hover:shadow-xl transition-all border border-border/50 backdrop-blur-sm bg-background/95 hover:bg-accent"
variant="outline"
title={"Scroll to bottom"}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
)}
</div>
<ChatError error={error} onDismiss={() => setError(null)} />
<ChatInput chatId={chatId} />
</div>
)}
<VersionPane
isVisible={isVersionPaneOpen}
onClose={() => setIsVersionPaneOpen(false)}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,159 @@
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from "./ui/command";
import { useState, useEffect } from "react";
import { useSearchChats } from "@/hooks/useSearchChats";
import type { ChatSummary, ChatSearchResult } from "@/lib/schemas";
type ChatSearchDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelectChat: ({ chatId, appId }: { chatId: number; appId: number }) => void;
appId: number | null;
allChats: ChatSummary[];
};
export function ChatSearchDialog({
open,
onOpenChange,
appId,
onSelectChat,
allChats,
}: ChatSearchDialogProps) {
const [searchQuery, setSearchQuery] = useState<string>("");
function useDebouncedValue<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState<T>(value);
useEffect(() => {
const handle = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(handle);
}, [value, delay]);
return debounced;
}
const debouncedQuery = useDebouncedValue(searchQuery, 150);
const { chats: searchResults } = useSearchChats(appId, debouncedQuery);
// Show all chats if search is empty, otherwise show search results
const chatsToShow = debouncedQuery.trim() === "" ? allChats : searchResults;
const commandFilter = (
value: string,
search: string,
keywords?: string[],
): number => {
const q = search.trim().toLowerCase();
if (!q) return 1;
const v = (value || "").toLowerCase();
if (v.includes(q)) {
// Higher score for earlier match in title/value
return 100 - Math.max(0, v.indexOf(q));
}
const foundInKeywords = (keywords || []).some((k) =>
(k || "").toLowerCase().includes(q),
);
return foundInKeywords ? 50 : 0;
};
function getSnippet(
text: string,
query: string,
radius = 50,
): {
before: string;
match: string;
after: string;
raw: string;
} {
const q = query.trim();
const lowerText = text;
const lowerQuery = q.toLowerCase();
const idx = lowerText.toLowerCase().indexOf(lowerQuery);
if (idx === -1) {
const raw =
text.length > radius * 2 ? text.slice(0, radius * 2) + "…" : text;
return { before: "", match: "", after: "", raw };
}
const start = Math.max(0, idx - radius);
const end = Math.min(text.length, idx + q.length + radius);
const before = (start > 0 ? "…" : "") + text.slice(start, idx);
const match = text.slice(idx, idx + q.length);
const after =
text.slice(idx + q.length, end) + (end < text.length ? "…" : "");
return { before, match, after, raw: before + match + after };
}
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onOpenChange(!open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, [open, onOpenChange]);
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
data-testid="chat-search-dialog"
filter={commandFilter}
>
<CommandInput
placeholder="Search chats"
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Chats">
{chatsToShow.map((chat) => {
const isSearch = searchQuery.trim() !== "";
const hasSnippet =
isSearch &&
"matchedMessageContent" in chat &&
(chat as ChatSearchResult).matchedMessageContent;
const snippet = hasSnippet
? getSnippet(
(chat as ChatSearchResult).matchedMessageContent as string,
searchQuery,
)
: null;
return (
<CommandItem
key={chat.id}
onSelect={() =>
onSelectChat({ chatId: chat.id, appId: chat.appId })
}
value={
(chat.title || "Untitled Chat") +
(snippet ? ` ${snippet.raw}` : "")
}
keywords={snippet ? [snippet.raw] : []}
>
<div className="flex flex-col">
<span>{chat.title || "Untitled Chat"}</span>
{snippet && (
<span className="text-xs text-muted-foreground mt-1 line-clamp-2">
{snippet.before}
<mark className="bg-transparent underline decoration-2 decoration-primary">
{snippet.match}
</mark>
{snippet.after}
</span>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</CommandDialog>
);
}

View File

@@ -0,0 +1,51 @@
import React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface CommunityCodeConsentDialogProps {
isOpen: boolean;
onAccept: () => void;
onCancel: () => void;
}
export const CommunityCodeConsentDialog: React.FC<
CommunityCodeConsentDialogProps
> = ({ isOpen, onAccept, onCancel }) => {
return (
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Community Code Notice</AlertDialogTitle>
<AlertDialogDescription className="space-y-3">
<p>
This code was created by a Dyad community member, not our core
team.
</p>
<p>
Community code can be very helpful, but since it's built
independently, it may have bugs, security risks, or could cause
issues with your system. We can't provide official support if
problems occur.
</p>
<p>
We recommend reviewing the code on GitHub first. Only proceed if
you're comfortable with these risks.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onAccept}>Accept</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,84 @@
import React from "react";
interface ConfirmationDialogProps {
isOpen: boolean;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
confirmButtonClass?: string;
onConfirm: () => void;
onCancel: () => void;
}
export default function ConfirmationDialog({
isOpen,
title,
message,
confirmText = "Confirm",
cancelText = "Cancel",
confirmButtonClass = "bg-red-600 hover:bg-red-700 focus:ring-red-500",
onConfirm,
onCancel,
}: ConfirmationDialogProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-screen items-center justify-center p-4 text-center sm:p-0">
<div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
onClick={onCancel}
/>
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-white dark:bg-gray-800 px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<svg
className="h-6 w-6 text-red-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900 dark:text-white">
{title}
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500 dark:text-gray-400">
{message}
</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-700 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
<button
type="button"
className={`inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm ${confirmButtonClass}`}
onClick={onConfirm}
>
{confirmText}
</button>
<button
type="button"
className="mt-3 inline-flex w-full justify-center rounded-md border border-gray-300 bg-white dark:bg-gray-600 dark:border-gray-500 dark:text-gray-200 px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:mt-0 sm:w-auto sm:text-sm"
onClick={onCancel}
>
{cancelText}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,412 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { InfoIcon, Settings2, Trash2 } from "lucide-react";
import { useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./ui/tooltip";
import { useSettings } from "@/hooks/useSettings";
import { useContextPaths } from "@/hooks/useContextPaths";
import type { ContextPathResult } from "@/lib/schemas";
export function ContextFilesPicker() {
const { settings } = useSettings();
const {
contextPaths,
smartContextAutoIncludes,
excludePaths,
updateContextPaths,
updateSmartContextAutoIncludes,
updateExcludePaths,
} = useContextPaths();
const [isOpen, setIsOpen] = useState(false);
const [newPath, setNewPath] = useState("");
const [newAutoIncludePath, setNewAutoIncludePath] = useState("");
const [newExcludePath, setNewExcludePath] = useState("");
const addPath = () => {
if (
newPath.trim() === "" ||
contextPaths.find((p: ContextPathResult) => p.globPath === newPath)
) {
setNewPath("");
return;
}
const newPaths = [
...contextPaths.map(({ globPath }: ContextPathResult) => ({ globPath })),
{
globPath: newPath,
},
];
updateContextPaths(newPaths);
setNewPath("");
};
const removePath = (pathToRemove: string) => {
const newPaths = contextPaths
.filter((p: ContextPathResult) => p.globPath !== pathToRemove)
.map(({ globPath }: ContextPathResult) => ({ globPath }));
updateContextPaths(newPaths);
};
const addAutoIncludePath = () => {
if (
newAutoIncludePath.trim() === "" ||
smartContextAutoIncludes.find(
(p: ContextPathResult) => p.globPath === newAutoIncludePath,
)
) {
setNewAutoIncludePath("");
return;
}
const newPaths = [
...smartContextAutoIncludes.map(({ globPath }: ContextPathResult) => ({
globPath,
})),
{
globPath: newAutoIncludePath,
},
];
updateSmartContextAutoIncludes(newPaths);
setNewAutoIncludePath("");
};
const removeAutoIncludePath = (pathToRemove: string) => {
const newPaths = smartContextAutoIncludes
.filter((p: ContextPathResult) => p.globPath !== pathToRemove)
.map(({ globPath }: ContextPathResult) => ({ globPath }));
updateSmartContextAutoIncludes(newPaths);
};
const addExcludePath = () => {
if (
newExcludePath.trim() === "" ||
excludePaths.find((p: ContextPathResult) => p.globPath === newExcludePath)
) {
setNewExcludePath("");
return;
}
const newPaths = [
...excludePaths.map(({ globPath }: ContextPathResult) => ({ globPath })),
{
globPath: newExcludePath,
},
];
updateExcludePaths(newPaths);
setNewExcludePath("");
};
const removeExcludePath = (pathToRemove: string) => {
const newPaths = excludePaths
.filter((p: ContextPathResult) => p.globPath !== pathToRemove)
.map(({ globPath }: ContextPathResult) => ({ globPath }));
updateExcludePaths(newPaths);
};
const isSmartContextEnabled =
settings?.enableDyadPro && settings?.enableProSmartFilesContextMode;
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
className="has-[>svg]:px-2"
size="sm"
data-testid="codebase-context-button"
>
<Settings2 className="size-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Codebase Context</TooltipContent>
</Tooltip>
<PopoverContent
className="w-96 max-h-[80vh] overflow-y-auto"
align="start"
>
<div className="relative space-y-4">
<div>
<h3 className="font-medium">Codebase Context</h3>
<p className="text-sm text-muted-foreground">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1 cursor-help">
Select the files to use as context.{" "}
<InfoIcon className="size-4" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
{isSmartContextEnabled ? (
<p>
With Smart Context, Dyad uses the most relevant files as
context.
</p>
) : (
<p>By default, Dyad uses your whole codebase.</p>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</p>
</div>
<div className="flex w-full max-w-sm items-center space-x-2">
<Input
data-testid="manual-context-files-input"
type="text"
placeholder="src/**/*.tsx"
value={newPath}
onChange={(e) => setNewPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
addPath();
}
}}
/>
<Button
type="submit"
onClick={addPath}
data-testid="manual-context-files-add-button"
>
Add
</Button>
</div>
<TooltipProvider>
{contextPaths.length > 0 ? (
<div className="space-y-2">
{contextPaths.map((p: ContextPathResult) => (
<div
key={p.globPath}
className="flex items-center justify-between gap-2 rounded-md border p-2"
>
<div className="flex flex-1 flex-col overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate font-mono text-sm">
{p.globPath}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{p.globPath}</p>
</TooltipContent>
</Tooltip>
<span className="text-xs text-muted-foreground">
{p.files} files, ~{p.tokens} tokens
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => removePath(p.globPath)}
data-testid="manual-context-files-remove-button"
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="rounded-md border border-dashed p-4 text-center">
<p className="text-sm text-muted-foreground">
{isSmartContextEnabled
? "Dyad will use Smart Context to automatically find the most relevant files to use as context."
: "Dyad will use the entire codebase as context."}
</p>
</div>
)}
</TooltipProvider>
<div className="pt-2">
<div>
<h3 className="font-medium">Exclude Paths</h3>
<p className="text-sm text-muted-foreground">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1 cursor-help">
These files will be excluded from the context.{" "}
<InfoIcon className="ml-2 size-4" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>
Exclude paths take precedence - files that match both
include and exclude patterns will be excluded.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</p>
</div>
<div className="flex w-full max-w-sm items-center space-x-2 mt-4">
<Input
data-testid="exclude-context-files-input"
type="text"
placeholder="node_modules/**/*"
value={newExcludePath}
onChange={(e) => setNewExcludePath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
addExcludePath();
}
}}
/>
<Button
type="submit"
onClick={addExcludePath}
data-testid="exclude-context-files-add-button"
>
Add
</Button>
</div>
<TooltipProvider>
{excludePaths.length > 0 && (
<div className="space-y-2 mt-4">
{excludePaths.map((p: ContextPathResult) => (
<div
key={p.globPath}
className="flex items-center justify-between gap-2 rounded-md border p-2 border-red-200"
>
<div className="flex flex-1 flex-col overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate font-mono text-sm text-red-600">
{p.globPath}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{p.globPath}</p>
</TooltipContent>
</Tooltip>
<span className="text-xs text-muted-foreground">
{p.files} files, ~{p.tokens} tokens
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => removeExcludePath(p.globPath)}
data-testid="exclude-context-files-remove-button"
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
))}
</div>
)}
</TooltipProvider>
</div>
{isSmartContextEnabled && (
<div className="pt-2">
<div>
<h3 className="font-medium">Smart Context Auto-includes</h3>
<p className="text-sm text-muted-foreground">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1 cursor-help">
These files will always be included in the context.{" "}
<InfoIcon className="ml-2 size-4" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>
Auto-include files are always included in the context
in addition to the files selected as relevant by Smart
Context.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</p>
</div>
<div className="flex w-full max-w-sm items-center space-x-2 mt-4">
<Input
data-testid="auto-include-context-files-input"
type="text"
placeholder="src/**/*.config.ts"
value={newAutoIncludePath}
onChange={(e) => setNewAutoIncludePath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
addAutoIncludePath();
}
}}
/>
<Button
type="submit"
onClick={addAutoIncludePath}
data-testid="auto-include-context-files-add-button"
>
Add
</Button>
</div>
<TooltipProvider>
{smartContextAutoIncludes.length > 0 && (
<div className="space-y-2 mt-4">
{smartContextAutoIncludes.map((p: ContextPathResult) => (
<div
key={p.globPath}
className="flex items-center justify-between gap-2 rounded-md border p-2"
>
<div className="flex flex-1 flex-col overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate font-mono text-sm">
{p.globPath}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{p.globPath}</p>
</TooltipContent>
</Tooltip>
<span className="text-xs text-muted-foreground">
{p.files} files, ~{p.tokens} tokens
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => removeAutoIncludePath(p.globPath)}
data-testid="auto-include-context-files-remove-button"
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
))}
</div>
)}
</TooltipProvider>
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,49 @@
import { Copy, Check } from "lucide-react";
import { useState } from "react";
interface CopyErrorMessageProps {
errorMessage: string;
className?: string;
}
export const CopyErrorMessage = ({
errorMessage,
className = "",
}: CopyErrorMessageProps) => {
const [isCopied, setIsCopied] = useState(false);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(errorMessage);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (err) {
console.error("Failed to copy error message:", err);
}
};
return (
<button
onClick={handleCopy}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
isCopied
? "bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300"
: "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"
} ${className}`}
title={isCopied ? "Copied!" : "Copy error message"}
>
{isCopied ? (
<>
<Check size={14} />
<span>Copied</span>
</>
) : (
<>
<Copy size={14} />
<span>Copy</span>
</>
)}
</button>
);
};

View File

@@ -0,0 +1,137 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useCreateApp } from "@/hooks/useCreateApp";
import { useCheckName } from "@/hooks/useCheckName";
import { useSetAtom } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { NEON_TEMPLATE_IDS, Template } from "@/shared/templates";
import { useRouter } from "@tanstack/react-router";
import { Loader2 } from "lucide-react";
import { neonTemplateHook } from "@/client_logic/template_hook";
import { showError } from "@/lib/toast";
interface CreateAppDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
template: Template | undefined;
}
export function CreateAppDialog({
open,
onOpenChange,
template,
}: CreateAppDialogProps) {
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
const [appName, setAppName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const { createApp } = useCreateApp();
const { data: nameCheckResult } = useCheckName(appName);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!appName.trim()) {
return;
}
if (nameCheckResult?.exists) {
return;
}
setIsSubmitting(true);
try {
const result = await createApp({ name: appName.trim() });
if (template && NEON_TEMPLATE_IDS.has(template.id)) {
await neonTemplateHook({
appId: result.app.id,
appName: result.app.name,
});
}
setSelectedAppId(result.app.id);
// Navigate to the new app's first chat
router.navigate({
to: "/chat",
search: { id: result.chatId },
});
setAppName("");
onOpenChange(false);
} catch (error) {
showError(error as any);
// Error is already handled by createApp hook or shown above
console.error("Error creating app:", error);
} finally {
setIsSubmitting(false);
}
};
const isNameValid = appName.trim().length > 0;
const nameExists = nameCheckResult?.exists;
const canSubmit = isNameValid && !nameExists && !isSubmitting;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create New App</DialogTitle>
<DialogDescription>
{`Create a new app using the ${template?.title} template.`}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="appName">App Name</Label>
<Input
id="appName"
value={appName}
onChange={(e) => setAppName(e.target.value)}
placeholder="Enter app name..."
className={nameExists ? "border-red-500" : ""}
disabled={isSubmitting}
/>
{nameExists && (
<p className="text-sm text-red-500">
An app with this name already exists
</p>
)}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="submit"
disabled={!canSubmit}
className="bg-indigo-600 hover:bg-indigo-700"
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isSubmitting ? "Creating..." : "Create App"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,200 @@
import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { IpcClient } from "@/ipc/ipc_client";
import { useMutation } from "@tanstack/react-query";
import { showError, showSuccess } from "@/lib/toast";
interface CreateCustomModelDialogProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
providerId: string;
}
export function CreateCustomModelDialog({
isOpen,
onClose,
onSuccess,
providerId,
}: CreateCustomModelDialogProps) {
const [apiName, setApiName] = useState("");
const [displayName, setDisplayName] = useState("");
const [description, setDescription] = useState("");
const [maxOutputTokens, setMaxOutputTokens] = useState<string>("");
const [contextWindow, setContextWindow] = useState<string>("");
const ipcClient = IpcClient.getInstance();
const mutation = useMutation({
mutationFn: async () => {
const params = {
apiName,
displayName,
providerId,
description: description || undefined,
maxOutputTokens: maxOutputTokens
? parseInt(maxOutputTokens, 10)
: undefined,
contextWindow: contextWindow ? parseInt(contextWindow, 10) : undefined,
};
if (!params.apiName) throw new Error("Model API name is required");
if (!params.displayName)
throw new Error("Model display name is required");
if (maxOutputTokens && isNaN(params.maxOutputTokens ?? NaN))
throw new Error("Max Output Tokens must be a valid number");
if (contextWindow && isNaN(params.contextWindow ?? NaN))
throw new Error("Context Window must be a valid number");
await ipcClient.createCustomLanguageModel(params);
},
onSuccess: () => {
showSuccess("Custom model created successfully!");
resetForm();
onSuccess(); // Refetch or update UI
onClose();
},
onError: (error) => {
showError(error);
},
});
const resetForm = () => {
setApiName("");
setDisplayName("");
setDescription("");
setMaxOutputTokens("");
setContextWindow("");
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate();
};
const handleClose = () => {
if (!mutation.isPending) {
resetForm();
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>Add Custom Model</DialogTitle>
<DialogDescription>
Configure a new language model for the selected provider.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="model-id" className="text-right">
Model ID*
</Label>
<Input
id="model-id"
value={apiName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setApiName(e.target.value)
}
className="col-span-3"
placeholder="This must match the model expected by the API"
required
disabled={mutation.isPending}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="model-name" className="text-right">
Name*
</Label>
<Input
id="model-name"
value={displayName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setDisplayName(e.target.value)
}
className="col-span-3"
placeholder="Human-friendly name for the model"
required
disabled={mutation.isPending}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
Description
</Label>
<Input
id="description"
value={description}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setDescription(e.target.value)
}
className="col-span-3"
placeholder="Optional: Describe the model's capabilities"
disabled={mutation.isPending}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="max-output-tokens" className="text-right">
Max Output Tokens
</Label>
<Input
id="max-output-tokens"
type="number"
value={maxOutputTokens}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMaxOutputTokens(e.target.value)
}
className="col-span-3"
placeholder="Optional: e.g., 4096"
disabled={mutation.isPending}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="context-window" className="text-right">
Context Window
</Label>
<Input
id="context-window"
type="number"
value={contextWindow}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setContextWindow(e.target.value)
}
className="col-span-3"
placeholder="Optional: e.g., 8192"
disabled={mutation.isPending}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Adding..." : "Add Model"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,213 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loader2 } from "lucide-react";
import { useCustomLanguageModelProvider } from "@/hooks/useCustomLanguageModelProvider";
import type { LanguageModelProvider } from "@/ipc/ipc_types";
interface CreateCustomProviderDialogProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
editingProvider?: LanguageModelProvider | null;
}
export function CreateCustomProviderDialog({
isOpen,
onClose,
onSuccess,
editingProvider = null,
}: CreateCustomProviderDialogProps) {
const [id, setId] = useState("");
const [name, setName] = useState("");
const [apiBaseUrl, setApiBaseUrl] = useState("");
const [envVarName, setEnvVarName] = useState("");
const [errorMessage, setErrorMessage] = useState("");
const isEditMode = Boolean(editingProvider);
const { createProvider, editProvider, isCreating, isEditing, error } =
useCustomLanguageModelProvider();
// Load provider data when editing
useEffect(() => {
if (editingProvider && isOpen) {
const cleanId = editingProvider.id?.startsWith("custom::")
? editingProvider.id.replace("custom::", "")
: editingProvider.id || "";
setId(cleanId);
setName(editingProvider.name || "");
setApiBaseUrl(editingProvider.apiBaseUrl || "");
setEnvVarName(editingProvider.envVarName || "");
} else if (!isOpen) {
// Reset form when dialog closes
setId("");
setName("");
setApiBaseUrl("");
setEnvVarName("");
setErrorMessage("");
}
}, [editingProvider, isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMessage("");
try {
if (isEditMode && editingProvider) {
const cleanId = editingProvider.id?.startsWith("custom::")
? editingProvider.id.replace("custom::", "")
: editingProvider.id || "";
await editProvider({
id: cleanId,
name: name.trim(),
apiBaseUrl: apiBaseUrl.trim(),
envVarName: envVarName.trim() || undefined,
});
} else {
await createProvider({
id: id.trim(),
name: name.trim(),
apiBaseUrl: apiBaseUrl.trim(),
envVarName: envVarName.trim() || undefined,
});
}
// Reset form
setId("");
setName("");
setApiBaseUrl("");
setEnvVarName("");
onSuccess();
} catch (error) {
setErrorMessage(
error instanceof Error
? error.message
: `Failed to ${isEditMode ? "edit" : "create"} custom provider`,
);
}
};
const handleClose = () => {
if (!isCreating && !isEditing) {
setErrorMessage("");
onClose();
}
};
const isLoading = isCreating || isEditing;
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{isEditMode ? "Edit Custom Provider" : "Add Custom Provider"}
</DialogTitle>
<DialogDescription>
{isEditMode
? "Update your custom language model provider configuration."
: "Connect to a custom language model provider API."}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 pt-4">
<div className="space-y-2">
<Label htmlFor="id">Provider ID</Label>
<Input
id="id"
value={id}
onChange={(e) => setId(e.target.value)}
placeholder="E.g., my-provider"
required
disabled={isLoading || isEditMode}
/>
<p className="text-xs text-muted-foreground">
A unique identifier for this provider (no spaces).
</p>
</div>
<div className="space-y-2">
<Label htmlFor="name">Display Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="E.g., My Provider"
required
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
The name that will be displayed in the UI.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="apiBaseUrl">API Base URL</Label>
<Input
id="apiBaseUrl"
value={apiBaseUrl}
onChange={(e) => setApiBaseUrl(e.target.value)}
placeholder="E.g., https://api.example.com/v1"
required
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
The base URL for the API endpoint.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="envVarName">Environment Variable (Optional)</Label>
<Input
id="envVarName"
value={envVarName}
onChange={(e) => setEnvVarName(e.target.value)}
placeholder="E.g., MY_PROVIDER_API_KEY"
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
Environment variable name for the API key.
</p>
</div>
{(errorMessage || error) && (
<div className="text-sm text-red-500">
{errorMessage ||
(error instanceof Error
? error.message
: "Failed to create custom provider")}
</div>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isLoading
? isEditMode
? "Updating..."
: "Adding..."
: isEditMode
? "Update Provider"
: "Add Provider"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,276 @@
import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Plus, Save, Edit2 } from "lucide-react";
interface CreateOrEditPromptDialogProps {
mode: "create" | "edit";
prompt?: {
id: number;
title: string;
description: string | null;
content: string;
};
onCreatePrompt?: (prompt: {
title: string;
description?: string;
content: string;
}) => Promise<any>;
onUpdatePrompt?: (prompt: {
id: number;
title: string;
description?: string;
content: string;
}) => Promise<any>;
trigger?: React.ReactNode;
prefillData?: {
title: string;
description: string;
content: string;
};
isOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function CreateOrEditPromptDialog({
mode,
prompt,
onCreatePrompt,
onUpdatePrompt,
trigger,
prefillData,
isOpen,
onOpenChange,
}: CreateOrEditPromptDialogProps) {
const [internalOpen, setInternalOpen] = useState(false);
const open = isOpen !== undefined ? isOpen : internalOpen;
const setOpen = onOpenChange || setInternalOpen;
const [draft, setDraft] = useState({
title: "",
description: "",
content: "",
});
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-resize textarea function
const adjustTextareaHeight = () => {
const textarea = textareaRef.current;
if (textarea) {
// Store current height to avoid flicker
const currentHeight = textarea.style.height;
textarea.style.height = "auto";
const scrollHeight = textarea.scrollHeight;
const maxHeight = window.innerHeight * 0.6 - 100; // 60vh in pixels
const minHeight = 150; // 150px minimum
const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
// Only update if height actually changed to reduce reflows
if (`${newHeight}px` !== currentHeight) {
textarea.style.height = `${newHeight}px`;
}
}
};
// Initialize draft with prompt data when editing or prefill data
useEffect(() => {
if (mode === "edit" && prompt) {
setDraft({
title: prompt.title,
description: prompt.description || "",
content: prompt.content,
});
} else if (prefillData) {
setDraft({
title: prefillData.title,
description: prefillData.description,
content: prefillData.content,
});
} else {
setDraft({ title: "", description: "", content: "" });
}
}, [mode, prompt, prefillData, open]);
// Auto-resize textarea when content changes
useEffect(() => {
adjustTextareaHeight();
}, [draft.content]);
// Trigger resize when dialog opens
useEffect(() => {
if (open) {
// Small delay to ensure the dialog is fully rendered
setTimeout(adjustTextareaHeight, 0);
}
}, [open]);
const resetDraft = () => {
if (mode === "edit" && prompt) {
setDraft({
title: prompt.title,
description: prompt.description || "",
content: prompt.content,
});
} else if (prefillData) {
setDraft({
title: prefillData.title,
description: prefillData.description,
content: prefillData.content,
});
} else {
setDraft({ title: "", description: "", content: "" });
}
};
const onSave = async () => {
if (!draft.title.trim() || !draft.content.trim()) return;
if (mode === "create" && onCreatePrompt) {
await onCreatePrompt({
title: draft.title.trim(),
description: draft.description.trim() || undefined,
content: draft.content,
});
} else if (mode === "edit" && onUpdatePrompt && prompt) {
await onUpdatePrompt({
id: prompt.id,
title: draft.title.trim(),
description: draft.description.trim() || undefined,
content: draft.content,
});
}
setOpen(false);
};
const handleCancel = () => {
resetDraft();
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
{trigger ? (
<DialogTrigger asChild>{trigger}</DialogTrigger>
) : mode === "create" ? (
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" /> New Prompt
</Button>
</DialogTrigger>
) : (
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
size="icon"
variant="ghost"
data-testid="edit-prompt-button"
>
<Edit2 className="h-4 w-4" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Edit prompt</p>
</TooltipContent>
</Tooltip>
)}
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{mode === "create" ? "Create New Prompt" : "Edit Prompt"}
</DialogTitle>
<DialogDescription>
{mode === "create"
? "Create a new prompt template for your library."
: "Edit your prompt template."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Input
placeholder="Title"
value={draft.title}
onChange={(e) => setDraft((d) => ({ ...d, title: e.target.value }))}
/>
<Input
placeholder="Description (optional)"
value={draft.description}
onChange={(e) =>
setDraft((d) => ({ ...d, description: e.target.value }))
}
/>
<Textarea
ref={textareaRef}
placeholder="Content"
value={draft.content}
onChange={(e) => {
setDraft((d) => ({ ...d, content: e.target.value }));
// Use requestAnimationFrame for smoother updates
requestAnimationFrame(adjustTextareaHeight);
}}
className="resize-none overflow-y-auto"
style={{ minHeight: "150px" }}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button
onClick={onSave}
disabled={!draft.title.trim() || !draft.content.trim()}
>
<Save className="mr-2 h-4 w-4" /> Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Backward compatibility wrapper for create mode
export function CreatePromptDialog({
onCreatePrompt,
prefillData,
isOpen,
onOpenChange,
}: {
onCreatePrompt: (prompt: {
title: string;
description?: string;
content: string;
}) => Promise<any>;
prefillData?: {
title: string;
description: string;
content: string;
};
isOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
return (
<CreateOrEditPromptDialog
mode="create"
onCreatePrompt={onCreatePrompt}
prefillData={prefillData}
isOpen={isOpen}
onOpenChange={onOpenChange}
/>
);
}

View File

@@ -0,0 +1,80 @@
import React from "react";
import { toast } from "sonner";
import { X, Copy, Check } from "lucide-react";
interface CustomErrorToastProps {
message: string;
toastId: string | number;
copied?: boolean;
onCopy?: () => void;
}
export function CustomErrorToast({
message,
toastId,
copied = false,
onCopy,
}: CustomErrorToastProps) {
const handleClose = () => {
toast.dismiss(toastId);
};
const handleCopy = () => {
if (onCopy) {
onCopy();
}
};
return (
<div className="relative bg-red-50/95 backdrop-blur-sm border border-red-200 rounded-xl shadow-lg min-w-[400px] max-w-[500px] overflow-hidden">
{/* Content */}
<div className="p-4">
<div className="flex items-start">
<div className="flex-1">
<div className="flex items-center mb-3">
<div className="flex-shrink-0">
<div className="w-5 h-5 bg-gradient-to-br from-red-400 to-red-500 rounded-full flex items-center justify-center shadow-sm">
<X className="w-3 h-3 text-white" />
</div>
</div>
<h3 className="ml-3 text-sm font-medium text-red-900">Error</h3>
{/* Action buttons */}
<div className="flex items-center space-x-1.5 ml-auto">
<button
onClick={(e) => {
e.stopPropagation();
handleCopy();
}}
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-100/70 rounded-lg transition-all duration-150"
title="Copy to clipboard"
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-100/70 rounded-lg transition-all duration-150"
title="Close"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div>
<p className="text-sm text-red-800 leading-relaxed whitespace-pre-wrap bg-red-100/50 backdrop-blur-sm p-3 rounded-lg border border-red-200/50">
{message}
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface DeleteConfirmationDialogProps {
itemName: string;
itemType?: string;
onDelete: () => void | Promise<void>;
trigger?: React.ReactNode;
}
export function DeleteConfirmationDialog({
itemName,
itemType = "item",
onDelete,
trigger,
}: DeleteConfirmationDialogProps) {
return (
<AlertDialog>
{trigger ? (
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
) : (
<Tooltip>
<TooltipTrigger asChild>
<AlertDialogTrigger asChild>
<Button
size="icon"
variant="ghost"
data-testid="delete-prompt-button"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Delete {itemType.toLowerCase()}</p>
</TooltipContent>
</Tooltip>
)}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {itemType}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{itemName}"? This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onDelete}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,52 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { CheckCircle, Sparkles } from "lucide-react";
interface DyadProSuccessDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function DyadProSuccessDialog({
isOpen,
onClose,
}: DyadProSuccessDialogProps) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-xl">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
<CheckCircle className="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
<span>Dyad Pro Enabled</span>
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="mb-4 text-base">
Congrats! Dyad Pro is now enabled in the app.
</p>
<div className="flex items-center gap-2 mb-3">
<Sparkles className="h-5 w-5 text-indigo-500" />
<p className="text-sm">You have access to leading AI models.</p>
</div>
<p className="text-sm text-muted-foreground">
You can click the Pro button at the top to access the settings at
any time.
</p>
</div>
<DialogFooter className="flex justify-end gap-2">
<Button onClick={onClose} variant="outline">
OK
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,240 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { IpcClient } from "@/ipc/ipc_client";
import { useSettings } from "@/hooks/useSettings";
import { useMutation } from "@tanstack/react-query";
import { showError, showSuccess } from "@/lib/toast";
interface Model {
apiName: string;
displayName: string;
description?: string;
maxOutputTokens?: number;
contextWindow?: number;
type: "cloud" | "custom";
tag?: string;
}
interface EditCustomModelDialogProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
providerId: string;
model: Model | null;
}
export function EditCustomModelDialog({
isOpen,
onClose,
onSuccess,
providerId,
model,
}: EditCustomModelDialogProps) {
const [apiName, setApiName] = useState("");
const [displayName, setDisplayName] = useState("");
const [description, setDescription] = useState("");
const [maxOutputTokens, setMaxOutputTokens] = useState<string>("");
const [contextWindow, setContextWindow] = useState<string>("");
const { settings, updateSettings } = useSettings();
const ipcClient = IpcClient.getInstance();
useEffect(() => {
if (model) {
setApiName(model.apiName);
setDisplayName(model.displayName);
setDescription(model.description || "");
setMaxOutputTokens(model.maxOutputTokens?.toString() || "");
setContextWindow(model.contextWindow?.toString() || "");
}
}, [model]);
const mutation = useMutation({
mutationFn: async () => {
if (!model) throw new Error("No model to edit");
const newParams = {
apiName,
displayName,
providerId,
description: description || undefined,
maxOutputTokens: maxOutputTokens
? parseInt(maxOutputTokens, 10)
: undefined,
contextWindow: contextWindow ? parseInt(contextWindow, 10) : undefined,
};
if (!newParams.apiName) throw new Error("Model API name is required");
if (!newParams.displayName)
throw new Error("Model display name is required");
if (maxOutputTokens && isNaN(newParams.maxOutputTokens ?? NaN))
throw new Error("Max Output Tokens must be a valid number");
if (contextWindow && isNaN(newParams.contextWindow ?? NaN))
throw new Error("Context Window must be a valid number");
// First delete the old model
await ipcClient.deleteCustomModel({
providerId,
modelApiName: model.apiName,
});
// Then create the new model
await ipcClient.createCustomLanguageModel(newParams);
},
onSuccess: async () => {
if (
settings?.selectedModel?.name === model?.apiName &&
settings?.selectedModel?.provider === providerId
) {
const newModel = {
...settings.selectedModel,
name: apiName,
};
try {
await updateSettings({ selectedModel: newModel });
} catch {
showError("Failed to update settings");
return; // stop closing dialog
}
}
showSuccess("Custom model updated successfully!");
onSuccess();
onClose();
},
onError: (error) => {
showError(error);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate();
};
const handleClose = () => {
if (!mutation.isPending) {
onClose();
}
};
if (!model) return null;
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>Edit Custom Model</DialogTitle>
<DialogDescription>
Modify the configuration of the selected language model.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-model-id" className="text-right">
Model ID*
</Label>
<Input
id="edit-model-id"
value={apiName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setApiName(e.target.value)
}
className="col-span-3"
placeholder="This must match the model expected by the API"
required
disabled={mutation.isPending}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-model-name" className="text-right">
Name*
</Label>
<Input
id="edit-model-name"
value={displayName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setDisplayName(e.target.value)
}
className="col-span-3"
placeholder="Human-friendly name for the model"
required
disabled={mutation.isPending}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-description" className="text-right">
Description
</Label>
<Input
id="edit-description"
value={description}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setDescription(e.target.value)
}
className="col-span-3"
placeholder="Optional: Describe the model's capabilities"
disabled={mutation.isPending}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-max-output-tokens" className="text-right">
Max Output Tokens
</Label>
<Input
id="edit-max-output-tokens"
type="number"
value={maxOutputTokens}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMaxOutputTokens(e.target.value)
}
className="col-span-3"
placeholder="Optional: e.g., 4096"
disabled={mutation.isPending}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-context-window" className="text-right">
Context Window
</Label>
<Input
id="edit-context-window"
type="number"
value={contextWindow}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setContextWindow(e.target.value)
}
className="col-span-3"
placeholder="Optional: e.g., 8192"
disabled={mutation.isPending}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Updating..." : "Update Model"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,113 @@
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { LightbulbIcon } from "lucide-react";
import { ErrorComponentProps } from "@tanstack/react-router";
import { usePostHog } from "posthog-js/react";
import { IpcClient } from "@/ipc/ipc_client";
export function ErrorBoundary({ error }: ErrorComponentProps) {
const [isLoading, setIsLoading] = useState(false);
const posthog = usePostHog();
useEffect(() => {
console.error("An error occurred in the route:", error);
posthog.captureException(error);
}, [error]);
const handleReportBug = async () => {
setIsLoading(true);
try {
// Get system debug info
const debugInfo = await IpcClient.getInstance().getSystemDebugInfo();
// Create a formatted issue body with the debug info and error information
const issueBody = `
## Bug Description
<!-- Please describe the issue you're experiencing -->
## Steps to Reproduce
<!-- Please list the steps to reproduce the issue -->
## Expected Behavior
<!-- What did you expect to happen? -->
## Actual Behavior
<!-- What actually happened? -->
## Error Details
- Error Name: ${error?.name || "Unknown"}
- Error Message: ${error?.message || "Unknown"}
${error?.stack ? `\n\`\`\`\n${error.stack.slice(0, 1000)}\n\`\`\`` : ""}
## System Information
- Dyad Version: ${debugInfo.dyadVersion}
- Platform: ${debugInfo.platform}
- Architecture: ${debugInfo.architecture}
- Node Version: ${debugInfo.nodeVersion || "Not available"}
- PNPM Version: ${debugInfo.pnpmVersion || "Not available"}
- Node Path: ${debugInfo.nodePath || "Not available"}
- Telemetry ID: ${debugInfo.telemetryId || "Not available"}
## Logs
\`\`\`
${debugInfo.logs.slice(-3_500) || "No logs available"}
\`\`\`
`;
// Create the GitHub issue URL with the pre-filled body
const encodedBody = encodeURIComponent(issueBody);
const encodedTitle = encodeURIComponent(
"[bug] Error in Dyad application",
);
const githubIssueUrl = `https://github.com/dyad-sh/dyad/issues/new?title=${encodedTitle}&labels=bug,filed-from-app,client-error&body=${encodedBody}`;
// Open the pre-filled GitHub issue page
await IpcClient.getInstance().openExternalUrl(githubIssueUrl);
} catch (err) {
console.error("Failed to prepare bug report:", err);
// Fallback to opening the regular GitHub issue page
IpcClient.getInstance().openExternalUrl(
"https://github.com/dyad-sh/dyad/issues/new",
);
} finally {
setIsLoading(false);
}
};
return (
<div className="flex flex-col items-center justify-center h-screen p-6">
<div className="max-w-md w-full bg-background p-6 rounded-lg shadow-lg">
<h2 className="text-xl font-bold mb-4">
Sorry, that shouldn't have happened!
</h2>
<p className="text-sm mb-3">There was an error loading the app...</p>
{error && (
<div className="bg-slate-100 dark:bg-slate-800 p-4 rounded-md mb-6">
<p className="text-sm mb-1">
<strong>Error name:</strong> {error.name}
</p>
<p className="text-sm">
<strong>Error message:</strong> {error.message}
</p>
</div>
)}
<div className="flex flex-col gap-2">
<Button onClick={handleReportBug} disabled={isLoading}>
{isLoading ? "Preparing report..." : "Report Bug"}
</Button>
</div>
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md flex items-center gap-2">
<LightbulbIcon className="h-4 w-4 text-blue-700 dark:text-blue-400 flex-shrink-0" />
<p className="text-sm text-blue-700 dark:text-blue-400">
<strong>Tip:</strong> Try closing and re-opening Dyad as a temporary
workaround.
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { AlertTriangle } from "lucide-react";
interface ForceCloseDialogProps {
isOpen: boolean;
onClose: () => void;
performanceData?: {
timestamp: number;
memoryUsageMB: number;
cpuUsagePercent?: number;
systemMemoryUsageMB?: number;
systemMemoryTotalMB?: number;
systemCpuPercent?: number;
};
}
export function ForceCloseDialog({
isOpen,
onClose,
performanceData,
}: ForceCloseDialogProps) {
const formatTimestamp = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
};
return (
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<AlertDialogContent className="max-w-2xl">
<AlertDialogHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
<AlertDialogTitle>Force Close Detected</AlertDialogTitle>
</div>
<AlertDialogDescription asChild>
<div className="space-y-4 pt-2">
<div className="text-base">
The app was not closed properly the last time it was running.
This could indicate a crash or unexpected termination.
</div>
{performanceData && (
<div className="rounded-lg border bg-muted/50 p-4 space-y-3">
<div className="font-semibold text-sm text-foreground">
Last Known State:{" "}
<span className="font-normal text-muted-foreground">
{formatTimestamp(performanceData.timestamp)}
</span>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
{/* Process Metrics */}
<div className="space-y-2">
<div className="font-medium text-foreground">
Process Metrics
</div>
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Memory:</span>
<span className="font-mono">
{performanceData.memoryUsageMB} MB
</span>
</div>
{performanceData.cpuUsagePercent !== undefined && (
<div className="flex justify-between">
<span className="text-muted-foreground">CPU:</span>
<span className="font-mono">
{performanceData.cpuUsagePercent}%
</span>
</div>
)}
</div>
</div>
{/* System Metrics */}
{(performanceData.systemMemoryUsageMB !== undefined ||
performanceData.systemCpuPercent !== undefined) && (
<div className="space-y-2">
<div className="font-medium text-foreground">
System Metrics
</div>
<div className="space-y-1">
{performanceData.systemMemoryUsageMB !== undefined &&
performanceData.systemMemoryTotalMB !==
undefined && (
<div className="flex justify-between">
<span className="text-muted-foreground">
Memory:
</span>
<span className="font-mono">
{performanceData.systemMemoryUsageMB} /{" "}
{performanceData.systemMemoryTotalMB} MB
</span>
</div>
)}
{performanceData.systemCpuPercent !== undefined && (
<div className="flex justify-between">
<span className="text-muted-foreground">
CPU:
</span>
<span className="font-mono">
{performanceData.systemCpuPercent}%
</span>
</div>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={onClose}>OK</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,940 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import {
Github,
Clipboard,
Check,
AlertTriangle,
ChevronRight,
} from "lucide-react";
import { IpcClient } from "@/ipc/ipc_client";
import { useSettings } from "@/hooks/useSettings";
import { useLoadApp } from "@/hooks/useLoadApp";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface GitHubConnectorProps {
appId: number | null;
folderName: string;
expanded?: boolean;
}
interface GitHubRepo {
name: string;
full_name: string;
private: boolean;
}
interface GitHubBranch {
name: string;
commit: { sha: string };
}
interface ConnectedGitHubConnectorProps {
appId: number;
app: any;
refreshApp: () => void;
triggerAutoSync?: boolean;
onAutoSyncComplete?: () => void;
}
export interface UnconnectedGitHubConnectorProps {
appId: number | null;
folderName: string;
settings: any;
refreshSettings: () => void;
handleRepoSetupComplete: () => void;
expanded?: boolean;
}
function ConnectedGitHubConnector({
appId,
app,
refreshApp,
triggerAutoSync,
onAutoSyncComplete,
}: ConnectedGitHubConnectorProps) {
const [isSyncing, setIsSyncing] = useState(false);
const [syncError, setSyncError] = useState<string | null>(null);
const [syncSuccess, setSyncSuccess] = useState<boolean>(false);
const [showForceDialog, setShowForceDialog] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
const [disconnectError, setDisconnectError] = useState<string | null>(null);
const autoSyncTriggeredRef = useRef(false);
const handleDisconnectRepo = async () => {
setIsDisconnecting(true);
setDisconnectError(null);
try {
await IpcClient.getInstance().disconnectGithubRepo(appId);
refreshApp();
} catch (err: any) {
setDisconnectError(err.message || "Failed to disconnect repository.");
} finally {
setIsDisconnecting(false);
}
};
const handleSyncToGithub = useCallback(
async (force: boolean = false) => {
setIsSyncing(true);
setSyncError(null);
setSyncSuccess(false);
setShowForceDialog(false);
try {
const result = await IpcClient.getInstance().syncGithubRepo(
appId,
force,
);
if (result.success) {
setSyncSuccess(true);
} else {
setSyncError(result.error || "Failed to sync to GitHub.");
// If it's a push rejection error, show the force dialog
if (
result.error?.includes("rejected") ||
result.error?.includes("non-fast-forward")
) {
// Don't show force dialog immediately, let user see the error first
}
}
} catch (err: any) {
setSyncError(err.message || "Failed to sync to GitHub.");
} finally {
setIsSyncing(false);
}
},
[appId],
);
// Auto-sync when triggerAutoSync prop is true
useEffect(() => {
if (triggerAutoSync && !autoSyncTriggeredRef.current) {
autoSyncTriggeredRef.current = true;
handleSyncToGithub(false).finally(() => {
onAutoSyncComplete?.();
});
} else if (!triggerAutoSync) {
// Reset the ref when triggerAutoSync becomes false
autoSyncTriggeredRef.current = false;
}
}, [triggerAutoSync]); // Only depend on triggerAutoSync to avoid unnecessary re-runs
return (
<div className="w-full" data-testid="github-connected-repo">
<p>Connected to GitHub Repo:</p>
<a
onClick={(e) => {
e.preventDefault();
IpcClient.getInstance().openExternalUrl(
`https://github.com/${app.githubOrg}/${app.githubRepo}`,
);
}}
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noopener noreferrer"
>
{app.githubOrg}/{app.githubRepo}
</a>
{app.githubBranch && (
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1">
Branch: <span className="font-mono">{app.githubBranch}</span>
</p>
)}
<div className="mt-2 flex gap-2">
<Button onClick={() => handleSyncToGithub(false)} disabled={isSyncing}>
{isSyncing ? (
<>
<svg
className="animate-spin h-5 w-5 mr-2 inline"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
style={{ display: "inline" }}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Syncing...
</>
) : (
"Sync to GitHub"
)}
</Button>
<Button
onClick={handleDisconnectRepo}
disabled={isDisconnecting}
variant="outline"
>
{isDisconnecting ? "Disconnecting..." : "Disconnect from repo"}
</Button>
</div>
{syncError && (
<div className="mt-2">
<p className="text-red-600">
{syncError}{" "}
<a
onClick={(e) => {
e.preventDefault();
IpcClient.getInstance().openExternalUrl(
"https://www.dyad.sh/docs/integrations/github#troubleshooting",
);
}}
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noopener noreferrer"
>
See troubleshooting guide
</a>
</p>
{(syncError.includes("rejected") ||
syncError.includes("non-fast-forward")) && (
<Button
onClick={() => setShowForceDialog(true)}
variant="outline"
size="sm"
className="mt-2 text-orange-600 border-orange-600 hover:bg-orange-50"
>
<AlertTriangle className="h-4 w-4 mr-2" />
Force Push (Dangerous)
</Button>
)}
</div>
)}
{syncSuccess && (
<p className="text-green-600 mt-2">Successfully pushed to GitHub!</p>
)}
{disconnectError && (
<p className="text-red-600 mt-2">{disconnectError}</p>
)}
{/* Force Push Warning Dialog */}
<Dialog open={showForceDialog} onOpenChange={setShowForceDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-orange-500" />
Force Push Warning
</DialogTitle>
<DialogDescription>
<div className="space-y-3">
<p>
You are about to perform a <strong>force push</strong> to your
GitHub repository.
</p>
<div className="bg-orange-50 dark:bg-orange-900/20 p-3 rounded-md border border-orange-200 dark:border-orange-800">
<p className="text-sm text-orange-800 dark:text-orange-200">
<strong>
This is dangerous and non-reversible and will:
</strong>
</p>
<ul className="text-sm text-orange-700 dark:text-orange-300 list-disc list-inside mt-2 space-y-1">
<li>Overwrite the remote repository history</li>
<li>
Permanently delete commits that exist on the remote but
not locally
</li>
</ul>
</div>
<p className="text-sm">
Only proceed if you're certain this is what you want to do.
</p>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowForceDialog(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => handleSyncToGithub(true)}
disabled={isSyncing}
>
{isSyncing ? "Force Pushing..." : "Force Push"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export function UnconnectedGitHubConnector({
appId,
folderName,
settings,
refreshSettings,
handleRepoSetupComplete,
expanded,
}: UnconnectedGitHubConnectorProps) {
// --- Collapsible State ---
const [isExpanded, setIsExpanded] = useState(expanded || false);
// --- GitHub Device Flow State ---
const [githubUserCode, setGithubUserCode] = useState<string | null>(null);
const [githubVerificationUri, setGithubVerificationUri] = useState<
string | null
>(null);
const [githubError, setGithubError] = useState<string | null>(null);
const [isConnectingToGithub, setIsConnectingToGithub] = useState(false);
const [githubStatusMessage, setGithubStatusMessage] = useState<string | null>(
null,
);
const [codeCopied, setCodeCopied] = useState(false);
// --- Repo Setup State ---
const [repoSetupMode, setRepoSetupMode] = useState<"create" | "existing">(
"create",
);
const [availableRepos, setAvailableRepos] = useState<GitHubRepo[]>([]);
const [isLoadingRepos, setIsLoadingRepos] = useState(false);
const [selectedRepo, setSelectedRepo] = useState<string>("");
const [availableBranches, setAvailableBranches] = useState<GitHubBranch[]>(
[],
);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [selectedBranch, setSelectedBranch] = useState<string>("main");
const [branchInputMode, setBranchInputMode] = useState<"select" | "custom">(
"select",
);
const [customBranchName, setCustomBranchName] = useState<string>("");
// Create new repo state
const [repoName, setRepoName] = useState(folderName);
const [repoAvailable, setRepoAvailable] = useState<boolean | null>(null);
const [repoCheckError, setRepoCheckError] = useState<string | null>(null);
const [isCheckingRepo, setIsCheckingRepo] = useState(false);
const [isCreatingRepo, setIsCreatingRepo] = useState(false);
const [createRepoError, setCreateRepoError] = useState<string | null>(null);
const [createRepoSuccess, setCreateRepoSuccess] = useState<boolean>(false);
// Assume org is the authenticated user for now (could add org input later)
const githubOrg = ""; // Use empty string for now (GitHub API will default to the authenticated user)
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleConnectToGithub = async () => {
setIsConnectingToGithub(true);
setGithubError(null);
setGithubUserCode(null);
setGithubVerificationUri(null);
setGithubStatusMessage("Requesting device code from GitHub...");
// Send IPC message to main process to start the flow
IpcClient.getInstance().startGithubDeviceFlow(appId);
};
useEffect(() => {
const cleanupFunctions: (() => void)[] = [];
// Listener for updates (user code, verification uri, status messages)
const removeUpdateListener =
IpcClient.getInstance().onGithubDeviceFlowUpdate((data) => {
console.log("Received github:flow-update", data);
if (data.userCode) {
setGithubUserCode(data.userCode);
}
if (data.verificationUri) {
setGithubVerificationUri(data.verificationUri);
}
if (data.message) {
setGithubStatusMessage(data.message);
}
setGithubError(null); // Clear previous errors on new update
if (!data.userCode && !data.verificationUri && data.message) {
// Likely just a status message, keep connecting state
setIsConnectingToGithub(true);
}
if (data.userCode && data.verificationUri) {
setIsConnectingToGithub(true); // Still connecting until success/error
}
});
cleanupFunctions.push(removeUpdateListener);
// Listener for success
const removeSuccessListener =
IpcClient.getInstance().onGithubDeviceFlowSuccess((data) => {
console.log("Received github:flow-success", data);
setGithubStatusMessage("Successfully connected to GitHub!");
setGithubUserCode(null); // Clear user-facing info
setGithubVerificationUri(null);
setGithubError(null);
setIsConnectingToGithub(false);
refreshSettings();
setIsExpanded(true);
});
cleanupFunctions.push(removeSuccessListener);
// Listener for errors
const removeErrorListener = IpcClient.getInstance().onGithubDeviceFlowError(
(data) => {
console.log("Received github:flow-error", data);
setGithubError(data.error || "An unknown error occurred.");
setGithubStatusMessage(null);
setGithubUserCode(null);
setGithubVerificationUri(null);
setIsConnectingToGithub(false);
},
);
cleanupFunctions.push(removeErrorListener);
// Cleanup function to remove all listeners when component unmounts or appId changes
return () => {
cleanupFunctions.forEach((cleanup) => cleanup());
// Reset state when appId changes or component unmounts
setGithubUserCode(null);
setGithubVerificationUri(null);
setGithubError(null);
setIsConnectingToGithub(false);
setGithubStatusMessage(null);
};
}, []); // Re-run effect if appId changes
// Load available repos when GitHub is connected
useEffect(() => {
if (settings?.githubAccessToken && repoSetupMode === "existing") {
loadAvailableRepos();
}
}, [settings?.githubAccessToken, repoSetupMode]);
const loadAvailableRepos = async () => {
setIsLoadingRepos(true);
try {
const repos = await IpcClient.getInstance().listGithubRepos();
setAvailableRepos(repos);
} catch (error) {
console.error("Failed to load GitHub repos:", error);
} finally {
setIsLoadingRepos(false);
}
};
// Load branches when a repo is selected
useEffect(() => {
if (selectedRepo && repoSetupMode === "existing") {
loadRepoBranches();
}
}, [selectedRepo, repoSetupMode]);
const loadRepoBranches = async () => {
if (!selectedRepo) return;
setIsLoadingBranches(true);
setBranchInputMode("select"); // Reset to select mode when loading new repo
setCustomBranchName(""); // Clear custom branch name
try {
const [owner, repo] = selectedRepo.split("/");
const branches = await IpcClient.getInstance().getGithubRepoBranches(
owner,
repo,
);
setAvailableBranches(branches);
// Default to main if available, otherwise first branch
const defaultBranch =
branches.find((b) => b.name === "main" || b.name === "master") ||
branches[0];
if (defaultBranch) {
setSelectedBranch(defaultBranch.name);
}
} catch (error) {
console.error("Failed to load repo branches:", error);
} finally {
setIsLoadingBranches(false);
}
};
const checkRepoAvailability = useCallback(
async (name: string) => {
setRepoCheckError(null);
setRepoAvailable(null);
if (!name) return;
setIsCheckingRepo(true);
try {
const result = await IpcClient.getInstance().checkGithubRepoAvailable(
githubOrg,
name,
);
setRepoAvailable(result.available);
if (!result.available) {
setRepoCheckError(
result.error || "Repository name is not available.",
);
}
} catch (err: any) {
setRepoCheckError(err.message || "Failed to check repo availability.");
} finally {
setIsCheckingRepo(false);
}
},
[githubOrg],
);
const debouncedCheckRepoAvailability = useCallback(
(name: string) => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
debounceTimeoutRef.current = setTimeout(() => {
checkRepoAvailability(name);
}, 500);
},
[checkRepoAvailability],
);
const handleSetupRepo = async (e: React.FormEvent) => {
e.preventDefault();
if (!appId) return;
setCreateRepoError(null);
setIsCreatingRepo(true);
setCreateRepoSuccess(false);
try {
if (repoSetupMode === "create") {
await IpcClient.getInstance().createGithubRepo(
githubOrg,
repoName,
appId,
selectedBranch,
);
} else {
const [owner, repo] = selectedRepo.split("/");
const branchToUse =
branchInputMode === "custom" ? customBranchName : selectedBranch;
await IpcClient.getInstance().connectToExistingGithubRepo(
owner,
repo,
branchToUse,
appId,
);
}
setCreateRepoSuccess(true);
setRepoCheckError(null);
handleRepoSetupComplete();
} catch (err: any) {
setCreateRepoError(
err.message ||
`Failed to ${repoSetupMode === "create" ? "create" : "connect to"} repository.`,
);
} finally {
setIsCreatingRepo(false);
}
};
if (!settings?.githubAccessToken) {
return (
<div className="mt-1 w-full" data-testid="github-unconnected-repo">
<Button
onClick={handleConnectToGithub}
className="cursor-pointer w-full py-5 flex justify-center items-center gap-2"
size="lg"
variant="outline"
disabled={isConnectingToGithub} // Also disable if appId is null
>
Connect to GitHub
<Github className="h-5 w-5" />
{isConnectingToGithub && (
<svg
className="animate-spin h-5 w-5 ml-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
)}
</Button>
{/* GitHub Connection Status/Instructions */}
{(githubUserCode || githubStatusMessage || githubError) && (
<div className="mt-6 p-4 border rounded-md bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600">
<h4 className="font-medium mb-2">GitHub Connection</h4>
{githubError && (
<p className="text-red-600 dark:text-red-400 mb-2">
Error: {githubError}
</p>
)}
{githubUserCode && githubVerificationUri && (
<div className="mb-2">
<p>
1. Go to:
<a
href={githubVerificationUri} // Make it a direct link
onClick={(e) => {
e.preventDefault();
IpcClient.getInstance().openExternalUrl(
githubVerificationUri,
);
}}
target="_blank"
rel="noopener noreferrer"
className="ml-1 text-blue-600 hover:underline dark:text-blue-400"
>
{githubVerificationUri}
</a>
</p>
<p>
2. Enter code:
<strong className="ml-1 font-mono text-lg tracking-wider bg-gray-200 dark:bg-gray-600 px-2 py-0.5 rounded">
{githubUserCode}
</strong>
<button
className="ml-2 p-1 rounded-md hover:bg-gray-300 dark:hover:bg-gray-500 focus:outline-none"
onClick={() => {
if (githubUserCode) {
navigator.clipboard
.writeText(githubUserCode)
.then(() => {
setCodeCopied(true);
setTimeout(() => setCodeCopied(false), 2000);
})
.catch((err) =>
console.error("Failed to copy code:", err),
);
}
}}
title="Copy to clipboard"
>
{codeCopied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Clipboard className="h-4 w-4" />
)}
</button>
</p>
</div>
)}
{githubStatusMessage && (
<p className="text-sm text-gray-600 dark:text-gray-300">
{githubStatusMessage}
</p>
)}
</div>
)}
</div>
);
}
return (
<div className="w-full" data-testid="github-setup-repo">
{/* Collapsible Header */}
<button
type="button"
onClick={!isExpanded ? () => setIsExpanded(true) : undefined}
className={`w-full p-4 text-left transition-colors rounded-md flex items-center justify-between ${
!isExpanded
? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
: ""
}`}
>
<span className="font-medium">Set up your GitHub repo</span>
{isExpanded ? undefined : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
</button>
{/* Collapsible Content */}
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
isExpanded ? "max-h-[800px] opacity-100" : "max-h-0 opacity-0"
}`}
>
<div className="p-4 pt-0 space-y-4">
{/* Mode Selection */}
<div>
<div className="flex rounded-md border border-gray-200 dark:border-gray-700">
<Button
type="button"
variant={repoSetupMode === "create" ? "default" : "ghost"}
className={`flex-1 rounded-none rounded-l-md border-0 ${
repoSetupMode === "create"
? "bg-primary text-primary-foreground"
: "hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
onClick={() => {
setRepoSetupMode("create");
setCreateRepoError(null);
setCreateRepoSuccess(false);
}}
>
Create new repo
</Button>
<Button
type="button"
variant={repoSetupMode === "existing" ? "default" : "ghost"}
className={`flex-1 rounded-none rounded-r-md border-0 border-l border-gray-200 dark:border-gray-700 ${
repoSetupMode === "existing"
? "bg-primary text-primary-foreground"
: "hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
onClick={() => {
setRepoSetupMode("existing");
setCreateRepoError(null);
setCreateRepoSuccess(false);
}}
>
Connect to existing repo
</Button>
</div>
</div>
<form className="space-y-4" onSubmit={handleSetupRepo}>
{repoSetupMode === "create" ? (
<>
<div>
<Label className="block text-sm font-medium">
Repository Name
</Label>
<Input
data-testid="github-create-repo-name-input"
className="w-full mt-1"
value={repoName}
onChange={(e) => {
const newValue = e.target.value;
setRepoName(newValue);
setRepoAvailable(null);
setRepoCheckError(null);
debouncedCheckRepoAvailability(newValue);
}}
disabled={isCreatingRepo}
/>
{isCheckingRepo && (
<p className="text-xs text-gray-500 mt-1">
Checking availability...
</p>
)}
{repoAvailable === true && (
<p className="text-xs text-green-600 mt-1">
Repository name is available!
</p>
)}
{repoAvailable === false && (
<p className="text-xs text-red-600 mt-1">
{repoCheckError}
</p>
)}
</div>
</>
) : (
<>
<div>
<Label className="block text-sm font-medium">
Select Repository
</Label>
<Select
value={selectedRepo}
onValueChange={setSelectedRepo}
disabled={isLoadingRepos}
>
<SelectTrigger
className="w-full mt-1"
data-testid="github-repo-select"
>
<SelectValue
placeholder={
isLoadingRepos
? "Loading repositories..."
: "Select a repository"
}
/>
</SelectTrigger>
<SelectContent>
{availableRepos.map((repo) => (
<SelectItem key={repo.full_name} value={repo.full_name}>
{repo.full_name} {repo.private && "(private)"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
{/* Branch Selection */}
<div>
<Label className="block text-sm font-medium">Branch</Label>
{repoSetupMode === "existing" && selectedRepo ? (
<div className="space-y-2">
<Select
value={
branchInputMode === "select" ? selectedBranch : "custom"
}
onValueChange={(value) => {
if (value === "custom") {
setBranchInputMode("custom");
setCustomBranchName("");
} else {
setBranchInputMode("select");
setSelectedBranch(value);
}
}}
disabled={isLoadingBranches}
>
<SelectTrigger
className="w-full mt-1"
data-testid="github-branch-select"
>
<SelectValue
placeholder={
isLoadingBranches
? "Loading branches..."
: "Select a branch"
}
/>
</SelectTrigger>
<SelectContent>
{availableBranches.map((branch) => (
<SelectItem key={branch.name} value={branch.name}>
{branch.name}
</SelectItem>
))}
<SelectItem value="custom">
<span className="font-medium">
Type custom branch name
</span>
</SelectItem>
</SelectContent>
</Select>
{branchInputMode === "custom" && (
<Input
data-testid="github-custom-branch-input"
className="w-full"
value={customBranchName}
onChange={(e) => setCustomBranchName(e.target.value)}
placeholder="Enter branch name (e.g., feature/new-feature)"
disabled={isCreatingRepo}
/>
)}
</div>
) : (
<Input
className="w-full mt-1"
value={selectedBranch}
onChange={(e) => setSelectedBranch(e.target.value)}
placeholder="main"
disabled={isCreatingRepo}
data-testid="github-new-repo-branch-input"
/>
)}
</div>
<Button
type="submit"
disabled={
isCreatingRepo ||
(repoSetupMode === "create" &&
(repoAvailable === false || !repoName)) ||
(repoSetupMode === "existing" &&
(!selectedRepo ||
!selectedBranch ||
(branchInputMode === "custom" && !customBranchName.trim())))
}
>
{isCreatingRepo
? repoSetupMode === "create"
? "Creating..."
: "Connecting..."
: repoSetupMode === "create"
? "Create Repo"
: "Connect to Repo"}
</Button>
</form>
{createRepoError && (
<p className="text-red-600 mt-2">{createRepoError}</p>
)}
{createRepoSuccess && (
<p className="text-green-600 mt-2">
{repoSetupMode === "create"
? "Repository created and linked!"
: "Connected to repository!"}
</p>
)}
</div>
</div>
</div>
);
}
export function GitHubConnector({
appId,
folderName,
expanded,
}: GitHubConnectorProps) {
const { app, refreshApp } = useLoadApp(appId);
const { settings, refreshSettings } = useSettings();
const [pendingAutoSync, setPendingAutoSync] = useState(false);
const handleRepoSetupComplete = useCallback(() => {
setPendingAutoSync(true);
refreshApp();
}, [refreshApp]);
const handleAutoSyncComplete = useCallback(() => {
setPendingAutoSync(false);
}, []);
if (app?.githubOrg && app?.githubRepo && appId) {
return (
<ConnectedGitHubConnector
appId={appId}
app={app}
refreshApp={refreshApp}
triggerAutoSync={pendingAutoSync}
onAutoSyncComplete={handleAutoSyncComplete}
/>
);
} else {
return (
<UnconnectedGitHubConnector
appId={appId}
folderName={folderName}
settings={settings}
refreshSettings={refreshSettings}
handleRepoSetupComplete={handleRepoSetupComplete}
expanded={expanded}
/>
);
}
}

View File

@@ -0,0 +1,60 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Github } from "lucide-react";
import { useSettings } from "@/hooks/useSettings";
import { showSuccess, showError } from "@/lib/toast";
export function GitHubIntegration() {
const { settings, updateSettings } = useSettings();
const [isDisconnecting, setIsDisconnecting] = useState(false);
const handleDisconnectFromGithub = async () => {
setIsDisconnecting(true);
try {
const result = await updateSettings({
githubAccessToken: undefined,
});
if (result) {
showSuccess("Successfully disconnected from GitHub");
} else {
showError("Failed to disconnect from GitHub");
}
} catch (err: any) {
showError(
err.message || "An error occurred while disconnecting from GitHub",
);
} finally {
setIsDisconnecting(false);
}
};
const isConnected = !!settings?.githubAccessToken;
if (!isConnected) {
return null;
}
return (
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
GitHub Integration
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Your account is connected to GitHub.
</p>
</div>
<Button
onClick={handleDisconnectFromGithub}
variant="destructive"
size="sm"
disabled={isDisconnecting}
className="flex items-center gap-2"
>
{isDisconnecting ? "Disconnecting..." : "Disconnect from GitHub"}
<Github className="h-4 w-4" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,244 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { IpcClient } from "@/ipc/ipc_client";
import { v4 as uuidv4 } from "uuid";
import { LoadingBlock, VanillaMarkdownParser } from "@/components/LoadingBlock";
interface HelpBotDialogProps {
isOpen: boolean;
onClose: () => void;
}
interface Message {
role: "user" | "assistant";
content: string;
reasoning?: string;
}
export function HelpBotDialog({ isOpen, onClose }: HelpBotDialogProps) {
const [input, setInput] = useState("");
const [messages, setMessages] = useState<Message[]>([]);
const [streaming, setStreaming] = useState(false);
const [error, setError] = useState<string | null>(null);
const assistantBufferRef = useRef("");
const reasoningBufferRef = useRef("");
const flushTimerRef = useRef<number | null>(null);
const FLUSH_INTERVAL_MS = 100;
const sessionId = useMemo(() => uuidv4(), [isOpen]);
useEffect(() => {
if (!isOpen) {
// Clean up when dialog closes
setMessages([]);
setInput("");
setError(null);
assistantBufferRef.current = "";
reasoningBufferRef.current = "";
// Clear the flush timer
if (flushTimerRef.current) {
window.clearInterval(flushTimerRef.current);
flushTimerRef.current = null;
}
}
}, [isOpen]);
// Cleanup on component unmount
useEffect(() => {
return () => {
// Clear the flush timer on unmount
if (flushTimerRef.current) {
window.clearInterval(flushTimerRef.current);
flushTimerRef.current = null;
}
};
}, []);
const handleSend = async () => {
const trimmed = input.trim();
if (!trimmed || streaming) return;
setError(null); // Clear any previous errors
setMessages((prev) => [
...prev,
{ role: "user", content: trimmed },
{ role: "assistant", content: "", reasoning: "" },
]);
assistantBufferRef.current = "";
reasoningBufferRef.current = "";
setInput("");
setStreaming(true);
IpcClient.getInstance().startHelpChat(sessionId, trimmed, {
onChunk: (delta) => {
// Buffer assistant content; UI will flush on interval for smoothness
assistantBufferRef.current += delta;
},
onEnd: () => {
// Final flush then stop streaming
setMessages((prev) => {
const next = [...prev];
const lastIdx = next.length - 1;
if (lastIdx >= 0 && next[lastIdx].role === "assistant") {
next[lastIdx] = {
...next[lastIdx],
content: assistantBufferRef.current,
reasoning: reasoningBufferRef.current,
};
}
return next;
});
setStreaming(false);
if (flushTimerRef.current) {
window.clearInterval(flushTimerRef.current);
flushTimerRef.current = null;
}
},
onError: (errorMessage: string) => {
setError(errorMessage);
setStreaming(false);
// Clear the flush timer
if (flushTimerRef.current) {
window.clearInterval(flushTimerRef.current);
flushTimerRef.current = null;
}
// Clear the buffers
assistantBufferRef.current = "";
reasoningBufferRef.current = "";
// Remove the empty assistant message that was added optimistically
setMessages((prev) => {
const next = [...prev];
if (
next.length > 0 &&
next[next.length - 1].role === "assistant" &&
!next[next.length - 1].content
) {
next.pop();
}
return next;
});
},
});
// Start smooth flush interval
if (flushTimerRef.current) {
window.clearInterval(flushTimerRef.current);
}
flushTimerRef.current = window.setInterval(() => {
setMessages((prev) => {
const next = [...prev];
const lastIdx = next.length - 1;
if (lastIdx >= 0 && next[lastIdx].role === "assistant") {
const current = next[lastIdx];
// Only update if there's any new data to apply
if (
current.content !== assistantBufferRef.current ||
current.reasoning !== reasoningBufferRef.current
) {
next[lastIdx] = {
...current,
content: assistantBufferRef.current,
reasoning: reasoningBufferRef.current,
};
}
}
return next;
});
}, FLUSH_INTERVAL_MS);
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Dyad Help Bot</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3 h-[480px]">
{error && (
<div className="bg-destructive/10 border border-destructive/20 rounded-md p-3">
<div className="flex items-start gap-2">
<div className="text-destructive text-sm font-medium">
Error:
</div>
<div className="text-destructive text-sm flex-1">{error}</div>
<button
onClick={() => setError(null)}
className="text-destructive hover:text-destructive/80 text-xs"
>
</button>
</div>
</div>
)}
<div className="flex-1 overflow-auto rounded-md border p-3 bg-(--background-lightest)">
{messages.length === 0 ? (
<div className="space-y-3">
<div className="text-sm text-muted-foreground">
Ask a question about using Dyad.
</div>
<div className="text-xs text-muted-foreground/70 bg-muted/50 rounded-md p-3">
This conversation may be logged and used to improve the
product. Please do not put any sensitive information in here.
</div>
</div>
) : (
<div className="space-y-3">
{messages.map((m, i) => (
<div key={i}>
{m.role === "user" ? (
<div className="text-right">
<div className="inline-block rounded-lg px-3 py-2 bg-primary text-primary-foreground">
{m.content}
</div>
</div>
) : (
<div className="text-left">
{streaming && i === messages.length - 1 && (
<LoadingBlock
isStreaming={streaming && i === messages.length - 1}
/>
)}
{m.content && (
<div className="inline-block rounded-lg px-3 py-2 bg-muted prose dark:prose-invert prose-headings:mb-2 prose-p:my-1 prose-pre:my-0 max-w-none">
<VanillaMarkdownParser content={m.content} />
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
<div className="flex gap-2">
<input
className="flex-1 h-10 rounded-md border bg-background px-3 text-sm"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type your question..."
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
/>
<Button onClick={handleSend} disabled={streaming || !input.trim()}>
{streaming ? "Sending..." : "Send"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,482 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
BookOpenIcon,
BugIcon,
UploadIcon,
ChevronLeftIcon,
CheckIcon,
XIcon,
FileIcon,
SparklesIcon,
} from "lucide-react";
import { IpcClient } from "@/ipc/ipc_client";
import { useState, useEffect } from "react";
import { useAtomValue } from "jotai";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { ChatLogsData } from "@/ipc/ipc_types";
import { showError } from "@/lib/toast";
import { HelpBotDialog } from "./HelpBotDialog";
import { useSettings } from "@/hooks/useSettings";
import { BugScreenshotDialog } from "./BugScreenshotDialog";
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
interface HelpDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function HelpDialog({ isOpen, onClose }: HelpDialogProps) {
const [isLoading, setIsLoading] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [reviewMode, setReviewMode] = useState(false);
const [chatLogsData, setChatLogsData] = useState<ChatLogsData | null>(null);
const [uploadComplete, setUploadComplete] = useState(false);
const [sessionId, setSessionId] = useState("");
const [isHelpBotOpen, setIsHelpBotOpen] = useState(false);
const [isBugScreenshotOpen, setIsBugScreenshotOpen] = useState(false);
const selectedChatId = useAtomValue(selectedChatIdAtom);
const { settings } = useSettings();
const { userBudget } = useUserBudgetInfo();
const isDyadProUser = settings?.providerSettings?.["auto"]?.apiKey?.value;
// Function to reset all dialog state
const resetDialogState = () => {
setIsLoading(false);
setIsUploading(false);
setReviewMode(false);
setChatLogsData(null);
setUploadComplete(false);
setSessionId("");
};
// Reset state when dialog closes or reopens
useEffect(() => {
if (!isOpen) {
resetDialogState();
}
}, [isOpen]);
// Wrap the original onClose to also reset state
const handleClose = () => {
onClose();
};
const handleReportBug = async () => {
setIsLoading(true);
try {
// Get system debug info
const debugInfo = await IpcClient.getInstance().getSystemDebugInfo();
// Create a formatted issue body with the debug info
const issueBody = `
<!--
⚠️ IMPORTANT: All sections marked as required must be completed in English.
Issues that do not meet these requirements will be closed and may need to be resubmitted.
-->
## Bug Description (required)
<!-- Please describe the issue you're experiencing -->
## Steps to Reproduce (required)
<!-- Please list the steps to reproduce the issue -->
## Expected Behavior (required)
<!-- What did you expect to happen? -->
## Actual Behavior (required)
<!-- What actually happened? -->
## Screenshot (Optional)
<!-- Screenshot of the bug -->
## System Information
- Dyad Version: ${debugInfo.dyadVersion}
- Platform: ${debugInfo.platform}
- Architecture: ${debugInfo.architecture}
- Node Version: ${debugInfo.nodeVersion || "n/a"}
- PNPM Version: ${debugInfo.pnpmVersion || "n/a"}
- Node Path: ${debugInfo.nodePath || "n/a"}
- Pro User ID: ${userBudget?.redactedUserId || "n/a"}
- Telemetry ID: ${debugInfo.telemetryId || "n/a"}
- Model: ${debugInfo.selectedLanguageModel || "n/a"}
## Logs
\`\`\`
${debugInfo.logs.slice(-3_500) || "No logs available"}
\`\`\`
`;
// Create the GitHub issue URL with the pre-filled body
const encodedBody = encodeURIComponent(issueBody);
const encodedTitle = encodeURIComponent("[bug] <WRITE TITLE HERE>");
const labels = ["bug"];
if (isDyadProUser) {
labels.push("pro");
}
const githubIssueUrl = `https://github.com/dyad-sh/dyad/issues/new?title=${encodedTitle}&labels=${labels}&body=${encodedBody}`;
// Open the pre-filled GitHub issue page
IpcClient.getInstance().openExternalUrl(githubIssueUrl);
} catch (error) {
console.error("Failed to prepare bug report:", error);
// Fallback to opening the regular GitHub issue page
IpcClient.getInstance().openExternalUrl(
"https://github.com/dyad-sh/dyad/issues/new",
);
} finally {
setIsLoading(false);
}
};
const handleUploadChatSession = async () => {
if (!selectedChatId) {
alert("Please select a chat first");
return;
}
setIsUploading(true);
try {
// Get chat logs (includes debug info, chat data, and codebase)
const chatLogs =
await IpcClient.getInstance().getChatLogs(selectedChatId);
// Store data for review and switch to review mode
setChatLogsData(chatLogs);
setReviewMode(true);
} catch (error) {
console.error("Failed to upload chat session:", error);
alert(
"Failed to upload chat session. Please try again or report manually.",
);
} finally {
setIsUploading(false);
}
};
const handleSubmitChatLogs = async () => {
if (!chatLogsData) return;
setIsUploading(true);
try {
// Prepare data for upload
const chatLogsJson = {
systemInfo: chatLogsData.debugInfo,
chat: chatLogsData.chat,
codebaseSnippet: chatLogsData.codebase,
};
// Get signed URL
const response = await fetch(
"https://upload-logs.dyad.sh/generate-upload-url",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
extension: "json",
contentType: "application/json",
}),
},
);
if (!response.ok) {
showError(`Failed to get upload URL: ${response.statusText}`);
throw new Error(`Failed to get upload URL: ${response.statusText}`);
}
const { uploadUrl, filename } = await response.json();
await IpcClient.getInstance().uploadToSignedUrl(
uploadUrl,
"application/json",
chatLogsJson,
);
// Extract session ID (filename without extension)
const sessionId = filename.replace(".json", "");
setSessionId(sessionId);
setUploadComplete(true);
setReviewMode(false);
} catch (error) {
console.error("Failed to upload chat logs:", error);
alert("Failed to upload chat logs. Please try again.");
} finally {
setIsUploading(false);
}
};
const handleCancelReview = () => {
setReviewMode(false);
setChatLogsData(null);
};
const handleOpenGitHubIssue = () => {
// Create a GitHub issue with the session ID
const issueBody = `
<!--
⚠️ IMPORTANT: All sections marked as required must be completed in English.
Issues that do not meet these requirements will be closed and may need to be resubmitted.
-->
Session ID: ${sessionId}
Pro User ID: ${userBudget?.redactedUserId || "n/a"}
## Issue Description (required)
<!-- Please describe the issue you're experiencing -->
## Expected Behavior (required)
<!-- What did you expect to happen? -->
## Actual Behavior (required)
<!-- What actually happened? -->
`;
const encodedBody = encodeURIComponent(issueBody);
const encodedTitle = encodeURIComponent("[session report] <add title>");
const labels = ["support"];
if (isDyadProUser) {
labels.push("pro");
}
const githubIssueUrl = `https://github.com/dyad-sh/dyad/issues/new?title=${encodedTitle}&labels=${labels}&body=${encodedBody}`;
IpcClient.getInstance().openExternalUrl(githubIssueUrl);
handleClose();
};
if (uploadComplete) {
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload Complete</DialogTitle>
</DialogHeader>
<div className="py-6 flex flex-col items-center space-y-4">
<div className="bg-green-50 dark:bg-green-900/20 p-6 rounded-full">
<CheckIcon className="h-8 w-8 text-green-600 dark:text-green-400" />
</div>
<h3 className="text-lg font-medium">
Chat Logs Uploaded Successfully
</h3>
<div className="bg-slate-100 dark:bg-slate-800 p-3 rounded flex items-center space-x-2 font-mono text-sm">
<FileIcon
className="h-4 w-4 cursor-pointer"
onClick={async () => {
try {
await navigator.clipboard.writeText(sessionId);
} catch (err) {
console.error("Failed to copy session ID:", err);
}
}}
/>
<span>{sessionId}</span>
</div>
<p className="text-center text-sm">
You must open a GitHub issue for us to investigate. Without a
linked issue, your report will not be reviewed.
</p>
</div>
<DialogFooter>
<Button onClick={handleOpenGitHubIssue} className="w-full">
Open GitHub Issue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
if (reviewMode && chatLogsData) {
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center">
<Button
variant="ghost"
className="mr-2 p-0 h-8 w-8"
onClick={handleCancelReview}
>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
OK to upload chat session?
</DialogTitle>
</DialogHeader>
<DialogDescription>
Please review the information that will be submitted. Your chat
messages, system information, and a snapshot of your codebase will
be included.
</DialogDescription>
<div className="space-y-4 overflow-y-auto flex-grow">
<div className="border rounded-md p-3">
<h3 className="font-medium mb-2">Chat Messages</h3>
<div className="text-sm bg-slate-50 dark:bg-slate-900 rounded p-2 max-h-40 overflow-y-auto">
{chatLogsData.chat.messages.map((msg) => (
<div key={msg.id} className="mb-2">
<span className="font-semibold">
{msg.role === "user" ? "You" : "Assistant"}:{" "}
</span>
<span>{msg.content}</span>
</div>
))}
</div>
</div>
<div className="border rounded-md p-3">
<h3 className="font-medium mb-2">Codebase Snapshot</h3>
<div className="text-sm bg-slate-50 dark:bg-slate-900 rounded p-2 max-h-40 overflow-y-auto font-mono">
{chatLogsData.codebase}
</div>
</div>
<div className="border rounded-md p-3">
<h3 className="font-medium mb-2">Logs</h3>
<div className="text-sm bg-slate-50 dark:bg-slate-900 rounded p-2 max-h-40 overflow-y-auto font-mono">
{chatLogsData.debugInfo.logs}
</div>
</div>
<div className="border rounded-md p-3">
<h3 className="font-medium mb-2">System Information</h3>
<div className="text-sm bg-slate-50 dark:bg-slate-900 rounded p-2 max-h-32 overflow-y-auto">
<p>Dyad Version: {chatLogsData.debugInfo.dyadVersion}</p>
<p>Platform: {chatLogsData.debugInfo.platform}</p>
<p>Architecture: {chatLogsData.debugInfo.architecture}</p>
<p>
Node Version:{" "}
{chatLogsData.debugInfo.nodeVersion || "Not available"}
</p>
</div>
</div>
</div>
<div className="flex justify-between mt-4 pt-2 sticky bottom-0 bg-background">
<Button
variant="outline"
onClick={handleCancelReview}
className="flex items-center"
>
<XIcon className="mr-2 h-4 w-4" /> Cancel
</Button>
<Button
onClick={handleSubmitChatLogs}
className="flex items-center"
disabled={isUploading}
>
{isUploading ? (
"Uploading..."
) : (
<>
<CheckIcon className="mr-2 h-4 w-4" /> Upload
</>
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Need help with Dyad?</DialogTitle>
</DialogHeader>
<DialogDescription className="">
If you need help or want to report an issue, here are some options:
</DialogDescription>
<div className="flex flex-col space-y-4 w-full">
{isDyadProUser ? (
<div className="flex flex-col space-y-2">
<Button
variant="default"
onClick={() => {
setIsHelpBotOpen(true);
}}
className="w-full py-6 border-primary/50 shadow-sm shadow-primary/10 transition-all hover:shadow-md hover:shadow-primary/15"
>
<SparklesIcon className="mr-2 h-5 w-5" /> Chat with Dyad help
bot (Pro)
</Button>
<p className="text-sm text-muted-foreground px-2">
Opens an in-app help chat assistant that searches through Dyad's
docs.
</p>
</div>
) : (
<div className="flex flex-col space-y-2">
<Button
variant="outline"
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://www.dyad.sh/docs",
);
}}
className="w-full py-6 bg-(--background-lightest)"
>
<BookOpenIcon className="mr-2 h-5 w-5" /> Open Docs
</Button>
<p className="text-sm text-muted-foreground px-2">
Get help with common questions and issues.
</p>
</div>
)}
<div className="flex flex-col space-y-2">
<Button
variant="outline"
onClick={() => {
handleClose();
setIsBugScreenshotOpen(true);
}}
disabled={isLoading}
className="w-full py-6 bg-(--background-lightest)"
>
<BugIcon className="mr-2 h-5 w-5" />{" "}
{isLoading ? "Preparing Report..." : "Report a Bug"}
</Button>
<p className="text-sm text-muted-foreground px-2">
We'll auto-fill your report with system info and logs. You can
review it for any sensitive info before submitting.
</p>
</div>
<div className="flex flex-col space-y-2">
<Button
variant="outline"
onClick={handleUploadChatSession}
disabled={isUploading || !selectedChatId}
className="w-full py-6 bg-(--background-lightest)"
>
<UploadIcon className="mr-2 h-5 w-5" />{" "}
{isUploading ? "Preparing Upload..." : "Upload Chat Session"}
</Button>
<p className="text-sm text-muted-foreground px-2">
Share chat logs and code for troubleshooting. Data is used only to
resolve your issue and auto-deleted after a limited time.
</p>
</div>
</div>
</DialogContent>
<HelpBotDialog
isOpen={isHelpBotOpen}
onClose={() => setIsHelpBotOpen(false)}
/>
<BugScreenshotDialog
isOpen={isBugScreenshotOpen}
onClose={() => setIsBugScreenshotOpen(false)}
handleReportBug={handleReportBug}
isLoading={isLoading}
/>
</Dialog>
);
}

View File

@@ -0,0 +1,27 @@
import { Button } from "@/components/ui/button";
import { Upload } from "lucide-react";
import { useState } from "react";
import { ImportAppDialog } from "./ImportAppDialog";
export function ImportAppButton() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
return (
<>
<div className="px-4 pb-1 flex justify-center">
<Button
variant="default"
size="default"
onClick={() => setIsDialogOpen(true)}
>
<Upload className="mr-2 h-4 w-4" />
Import App
</Button>
</div>
<ImportAppDialog
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
/>
</>
);
}

View File

@@ -0,0 +1,727 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { IpcClient } from "@/ipc/ipc_client";
import { useMutation } from "@tanstack/react-query";
import { showError, showSuccess } from "@/lib/toast";
import { Folder, X, Loader2, Info } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Label } from "@radix-ui/react-label";
import { useNavigate } from "@tanstack/react-router";
import { useStreamChat } from "@/hooks/useStreamChat";
import type { GithubRepository } from "@/ipc/ipc_types";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useSetAtom } from "jotai";
import { useLoadApps } from "@/hooks/useLoadApps";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "./ui/accordion";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSettings } from "@/hooks/useSettings";
import { UnconnectedGitHubConnector } from "@/components/GitHubConnector";
interface ImportAppDialogProps {
isOpen: boolean;
onClose: () => void;
}
export const AI_RULES_PROMPT =
"Generate an AI_RULES.md file for this app. Describe the tech stack in 5-10 bullet points and describe clear rules about what libraries to use for what.";
export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [hasAiRules, setHasAiRules] = useState<boolean | null>(null);
const [customAppName, setCustomAppName] = useState<string>("");
const [nameExists, setNameExists] = useState<boolean>(false);
const [isCheckingName, setIsCheckingName] = useState<boolean>(false);
const [installCommand, setInstallCommand] = useState("");
const [startCommand, setStartCommand] = useState("");
const navigate = useNavigate();
const { streamMessage } = useStreamChat({ hasChatId: false });
const { refreshApps } = useLoadApps();
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
// GitHub import state
const [repos, setRepos] = useState<GithubRepository[]>([]);
const [loading, setLoading] = useState(false);
const [url, setUrl] = useState("");
const [importing, setImporting] = useState(false);
const { settings, refreshSettings } = useSettings();
const isAuthenticated = !!settings?.githubAccessToken;
const [githubAppName, setGithubAppName] = useState("");
const [githubNameExists, setGithubNameExists] = useState(false);
const [isCheckingGithubName, setIsCheckingGithubName] = useState(false);
useEffect(() => {
if (isOpen) {
setGithubAppName("");
setGithubNameExists(false);
// Fetch GitHub repos if authenticated
if (isAuthenticated) {
fetchRepos();
}
}
}, [isOpen, isAuthenticated]);
const fetchRepos = async () => {
setLoading(true);
try {
const fetchedRepos = await IpcClient.getInstance().listGithubRepos();
setRepos(fetchedRepos);
} catch (err: unknown) {
showError("Failed to fetch repositories.: " + (err as any).toString());
} finally {
setLoading(false);
}
};
const handleUrlBlur = async () => {
if (!url.trim()) return;
const repoName = extractRepoNameFromUrl(url);
if (repoName) {
setGithubAppName(repoName);
setIsCheckingGithubName(true);
try {
const result = await IpcClient.getInstance().checkAppName({
appName: repoName,
});
setGithubNameExists(result.exists);
} catch (error: unknown) {
showError("Failed to check app name: " + (error as any).toString());
} finally {
setIsCheckingGithubName(false);
}
}
};
const extractRepoNameFromUrl = (url: string): string | null => {
const match = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
return match ? match[2] : null;
};
const handleImportFromUrl = async () => {
setImporting(true);
try {
const match = extractRepoNameFromUrl(url);
const repoName = match ? match[2] : "";
const appName = githubAppName.trim() || repoName;
const result = await IpcClient.getInstance().cloneRepoFromUrl({
url,
installCommand: installCommand.trim() || undefined,
startCommand: startCommand.trim() || undefined,
appName,
});
if ("error" in result) {
showError(result.error);
setImporting(false);
return;
}
setSelectedAppId(result.app.id);
showSuccess(`Successfully imported ${result.app.name}`);
const chatId = await IpcClient.getInstance().createChat(result.app.id);
navigate({ to: "/chat", search: { id: chatId } });
if (!result.hasAiRules) {
streamMessage({
prompt: AI_RULES_PROMPT,
chatId,
});
}
onClose();
} catch (error: unknown) {
showError("Failed to import repository: " + (error as any).toString());
} finally {
setImporting(false);
}
};
const handleSelectRepo = async (repo: GithubRepository) => {
setImporting(true);
try {
const appName = githubAppName.trim() || repo.name;
const result = await IpcClient.getInstance().cloneRepoFromUrl({
url: `https://github.com/${repo.full_name}.git`,
installCommand: installCommand.trim() || undefined,
startCommand: startCommand.trim() || undefined,
appName,
});
if ("error" in result) {
showError(result.error);
setImporting(false);
return;
}
setSelectedAppId(result.app.id);
showSuccess(`Successfully imported ${result.app.name}`);
const chatId = await IpcClient.getInstance().createChat(result.app.id);
navigate({ to: "/chat", search: { id: chatId } });
if (!result.hasAiRules) {
streamMessage({
prompt: AI_RULES_PROMPT,
chatId,
});
}
onClose();
} catch (error: unknown) {
showError("Failed to import repository: " + (error as any).toString());
} finally {
setImporting(false);
}
};
const handleGithubAppNameChange = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const newName = e.target.value;
setGithubAppName(newName);
if (newName.trim()) {
setIsCheckingGithubName(true);
try {
const result = await IpcClient.getInstance().checkAppName({
appName: newName,
});
setGithubNameExists(result.exists);
} catch (error: unknown) {
showError("Failed to check app name: " + (error as any).toString());
} finally {
setIsCheckingGithubName(false);
}
}
};
const checkAppName = async (name: string): Promise<void> => {
setIsCheckingName(true);
try {
const result = await IpcClient.getInstance().checkAppName({
appName: name,
});
setNameExists(result.exists);
} catch (error: unknown) {
showError("Failed to check app name: " + (error as any).toString());
} finally {
setIsCheckingName(false);
}
};
const selectFolderMutation = useMutation({
mutationFn: async () => {
const result = await IpcClient.getInstance().selectAppFolder();
if (!result.path || !result.name) {
throw new Error("No folder selected");
}
const aiRulesCheck = await IpcClient.getInstance().checkAiRules({
path: result.path,
});
setHasAiRules(aiRulesCheck.exists);
setSelectedPath(result.path);
// Use the folder name from the IPC response
setCustomAppName(result.name);
// Check if the app name already exists
await checkAppName(result.name);
return result;
},
onError: (error: Error) => {
showError(error.message);
},
});
const importAppMutation = useMutation({
mutationFn: async () => {
if (!selectedPath) throw new Error("No folder selected");
return IpcClient.getInstance().importApp({
path: selectedPath,
appName: customAppName,
installCommand: installCommand || undefined,
startCommand: startCommand || undefined,
});
},
onSuccess: async (result) => {
showSuccess(
!hasAiRules
? "App imported successfully. Dyad will automatically generate an AI_RULES.md now."
: "App imported successfully",
);
onClose();
navigate({ to: "/chat", search: { id: result.chatId } });
if (!hasAiRules) {
streamMessage({
prompt: AI_RULES_PROMPT,
chatId: result.chatId,
});
}
setSelectedAppId(result.appId);
await refreshApps();
},
onError: (error: Error) => {
showError(error.message);
},
});
const handleSelectFolder = () => {
selectFolderMutation.mutate();
};
const handleImport = () => {
importAppMutation.mutate();
};
const handleClear = () => {
setSelectedPath(null);
setHasAiRules(null);
setCustomAppName("");
setNameExists(false);
setInstallCommand("");
setStartCommand("");
};
const handleAppNameChange = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const newName = e.target.value;
setCustomAppName(newName);
if (newName.trim()) {
await checkAppName(newName);
}
};
const hasInstallCommand = installCommand.trim().length > 0;
const hasStartCommand = startCommand.trim().length > 0;
const commandsValid = hasInstallCommand === hasStartCommand;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl w-[calc(100vw-2rem)] max-h-[98vh] overflow-y-auto flex flex-col p-0">
<DialogHeader className="sticky top-0 bg-background border-b px-6 py-4">
<DialogTitle>Import App</DialogTitle>
<DialogDescription className="text-sm">
Import existing app from local folder or clone from Github.
</DialogDescription>
</DialogHeader>
<div className="px-6 pb-6 overflow-y-auto flex-1">
<Alert className="border-blue-500/20 text-blue-500 mb-2">
<Info className="h-4 w-4 flex-shrink-0" />
<AlertDescription className="text-xs sm:text-sm">
App import is an experimental feature. If you encounter any
issues, please report them using the Help button.
</AlertDescription>
</Alert>
<Tabs defaultValue="local-folder" className="w-full">
<TabsList className="grid w-full grid-cols-3 h-auto">
<TabsTrigger
value="local-folder"
className="text-xs sm:text-sm px-2 py-2"
>
Local Folder
</TabsTrigger>
<TabsTrigger
value="github-repos"
className="text-xs sm:text-sm px-2 py-2"
>
<span className="hidden sm:inline">Your GitHub Repos</span>
<span className="sm:hidden">GitHub Repos</span>
</TabsTrigger>
<TabsTrigger
value="github-url"
className="text-xs sm:text-sm px-2 py-2"
>
GitHub URL
</TabsTrigger>
</TabsList>
<TabsContent value="local-folder" className="space-y-4">
<div className="py-4">
{!selectedPath ? (
<Button
onClick={handleSelectFolder}
disabled={selectFolderMutation.isPending}
className="w-full"
>
{selectFolderMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Folder className="mr-2 h-4 w-4" />
)}
{selectFolderMutation.isPending
? "Selecting folder..."
: "Select Folder"}
</Button>
) : (
<div className="space-y-4">
<div className="rounded-md border p-3 sm:p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1 overflow-hidden">
<p className="text-sm font-medium mb-1">
Selected folder:
</p>
<p className="text-xs sm:text-sm text-muted-foreground break-words">
{selectedPath}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleClear}
className="h-8 w-8 p-0 flex-shrink-0"
disabled={importAppMutation.isPending}
>
<X className="h-4 w-4" />
<span className="sr-only">Clear selection</span>
</Button>
</div>
</div>
<div className="space-y-2">
{nameExists && (
<p className="text-xs sm:text-sm text-yellow-500">
An app with this name already exists. Please choose a
different name:
</p>
)}
<div className="relative">
<Label className="text-xs sm:text-sm ml-2 mb-2">
App name
</Label>
<Input
value={customAppName}
onChange={handleAppNameChange}
placeholder="Enter new app name"
className="w-full pr-8 text-sm"
disabled={importAppMutation.isPending}
/>
{isCheckingName && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
</div>
<Accordion type="single" collapsible>
<AccordionItem value="advanced-options">
<AccordionTrigger className="text-xs sm:text-sm hover:no-underline">
Advanced options
</AccordionTrigger>
<AccordionContent className="space-y-4">
<div className="grid gap-2">
<Label className="text-xs sm:text-sm ml-2 mb-2">
Install command
</Label>
<Input
value={installCommand}
onChange={(e) =>
setInstallCommand(e.target.value)
}
placeholder="pnpm install"
className="text-sm"
disabled={importAppMutation.isPending}
/>
</div>
<div className="grid gap-2">
<Label className="text-xs sm:text-sm ml-2 mb-2">
Start command
</Label>
<Input
value={startCommand}
onChange={(e) => setStartCommand(e.target.value)}
placeholder="pnpm dev"
className="text-sm"
disabled={importAppMutation.isPending}
/>
</div>
{!commandsValid && (
<p className="text-xs sm:text-sm text-red-500">
Both commands are required when customizing.
</p>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
{hasAiRules === false && (
<Alert className="border-yellow-500/20 text-yellow-500 flex items-start gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 flex-shrink-0 mt-1" />
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
AI_RULES.md lets Dyad know which tech stack to
use for editing the app
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<AlertDescription className="text-xs sm:text-sm">
No AI_RULES.md found. Dyad will automatically generate
one after importing.
</AlertDescription>
</Alert>
)}
{importAppMutation.isPending && (
<div className="flex items-center justify-center space-x-2 text-xs sm:text-sm text-muted-foreground animate-pulse">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Importing app...</span>
</div>
)}
</div>
)}
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button
variant="outline"
onClick={onClose}
disabled={importAppMutation.isPending}
className="w-full sm:w-auto"
>
Cancel
</Button>
<Button
onClick={handleImport}
disabled={
!selectedPath ||
importAppMutation.isPending ||
nameExists ||
!commandsValid
}
className="w-full sm:w-auto min-w-[80px]"
>
{importAppMutation.isPending ? <>Importing...</> : "Import"}
</Button>
</DialogFooter>
</TabsContent>
<TabsContent value="github-repos" className="space-y-4">
{!isAuthenticated ? (
<UnconnectedGitHubConnector
appId={null}
folderName=""
settings={settings}
refreshSettings={refreshSettings}
handleRepoSetupComplete={() => undefined}
expanded={false}
/>
) : (
<>
{loading && (
<div className="flex justify-center py-8">
<Loader2 className="animate-spin h-6 w-6" />
</div>
)}
<div className="space-y-2">
<Label className="text-xs sm:text-sm ml-2 mb-2">
App name (optional)
</Label>
<Input
value={githubAppName}
onChange={handleGithubAppNameChange}
placeholder="Leave empty to use repository name"
className="w-full pr-8 text-sm"
disabled={importing}
/>
{isCheckingGithubName && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
{githubNameExists && (
<p className="text-xs sm:text-sm text-yellow-500">
An app with this name already exists. Please choose a
different name.
</p>
)}
</div>
<div className="flex flex-col space-y-2 max-h-64 overflow-y-auto overflow-x-hidden">
{!loading && repos.length === 0 && (
<p className="text-xs sm:text-sm text-muted-foreground text-center py-4">
No repositories found
</p>
)}
{repos.map((repo) => (
<div
key={repo.full_name}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-accent/50 transition-colors min-w-0"
>
<div className="min-w-0 flex-1 overflow-hidden mr-2">
<p className="font-semibold truncate text-sm">
{repo.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{repo.full_name}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleSelectRepo(repo)}
disabled={importing}
className="flex-shrink-0 text-xs"
>
{importing ? (
<Loader2 className="animate-spin h-4 w-4" />
) : (
"Import"
)}
</Button>
</div>
))}
</div>
{repos.length > 0 && (
<>
<Accordion type="single" collapsible>
<AccordionItem value="advanced-options">
<AccordionTrigger className="text-xs sm:text-sm hover:no-underline">
Advanced options
</AccordionTrigger>
<AccordionContent className="space-y-4">
<div className="grid gap-2">
<Label className="text-xs sm:text-sm">
Install command
</Label>
<Input
value={installCommand}
onChange={(e) =>
setInstallCommand(e.target.value)
}
placeholder="pnpm install"
className="text-sm"
disabled={importing}
/>
</div>
<div className="grid gap-2">
<Label className="text-xs sm:text-sm">
Start command
</Label>
<Input
value={startCommand}
onChange={(e) =>
setStartCommand(e.target.value)
}
placeholder="pnpm dev"
className="text-sm"
disabled={importing}
/>
</div>
{!commandsValid && (
<p className="text-xs sm:text-sm text-red-500">
Both commands are required when customizing.
</p>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</>
)}
</>
)}
</TabsContent>
<TabsContent value="github-url" className="space-y-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Repository URL</Label>
<Input
placeholder="https://github.com/user/repo.git"
value={url}
onChange={(e) => setUrl(e.target.value)}
disabled={importing}
onBlur={handleUrlBlur}
className="text-sm break-all"
/>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm">
App name (optional)
</Label>
<Input
value={githubAppName}
onChange={handleGithubAppNameChange}
placeholder="Leave empty to use repository name"
disabled={importing}
className="text-sm"
/>
{isCheckingGithubName && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
{githubNameExists && (
<p className="text-xs sm:text-sm text-yellow-500">
An app with this name already exists. Please choose a
different name.
</p>
)}
</div>
<Accordion type="single" collapsible>
<AccordionItem value="advanced-options">
<AccordionTrigger className="text-xs sm:text-sm hover:no-underline">
Advanced options
</AccordionTrigger>
<AccordionContent className="space-y-4">
<div className="grid gap-2">
<Label className="text-xs sm:text-sm">
Install command
</Label>
<Input
value={installCommand}
onChange={(e) => setInstallCommand(e.target.value)}
placeholder="pnpm install"
className="text-sm"
disabled={importing}
/>
</div>
<div className="grid gap-2">
<Label className="text-xs sm:text-sm">
Start command
</Label>
<Input
value={startCommand}
onChange={(e) => setStartCommand(e.target.value)}
placeholder="pnpm dev"
className="text-sm"
disabled={importing}
/>
</div>
{!commandsValid && (
<p className="text-xs sm:text-sm text-red-500">
Both commands are required when customizing.
</p>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
<Button
onClick={handleImportFromUrl}
disabled={importing || !url.trim() || !commandsValid}
className="w-full"
>
{importing ? (
<>
<Loader2 className="animate-spin mr-2 h-4 w-4" />
Importing...
</>
) : (
"Import"
)}
</Button>
</TabsContent>
</Tabs>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,89 @@
import React from "react";
import { toast } from "sonner";
import { X, AlertTriangle } from "lucide-react";
import { Button } from "./ui/button";
interface InputRequestToastProps {
message: string;
toastId: string | number;
onResponse: (response: "y" | "n") => void;
}
export function InputRequestToast({
message,
toastId,
onResponse,
}: InputRequestToastProps) {
const handleClose = () => {
toast.dismiss(toastId);
};
const handleResponse = (response: "y" | "n") => {
onResponse(response);
toast.dismiss(toastId);
};
// Clean up the message by removing excessive newlines and whitespace
const cleanMessage = message
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join("\n");
return (
<div className="relative bg-amber-50/95 dark:bg-slate-800/95 backdrop-blur-sm border border-amber-200 dark:border-slate-600 rounded-xl shadow-lg min-w-[400px] max-w-[500px] overflow-hidden">
{/* Content */}
<div className="p-5">
<div className="flex items-start">
<div className="flex-1">
<div className="flex items-center mb-4">
<div className="flex-shrink-0">
<div className="w-6 h-6 bg-gradient-to-br from-amber-500 to-amber-600 dark:from-amber-400 dark:to-amber-500 rounded-full flex items-center justify-center shadow-sm">
<AlertTriangle className="w-3.5 h-3.5 text-white" />
</div>
</div>
<h3 className="ml-3 text-base font-semibold text-amber-900 dark:text-amber-100">
Input Required
</h3>
{/* Close button */}
<button
onClick={handleClose}
className="ml-auto flex-shrink-0 p-1.5 text-amber-500 dark:text-slate-400 hover:text-amber-700 dark:hover:text-slate-200 transition-colors duration-200 rounded-md hover:bg-amber-100/50 dark:hover:bg-slate-700/50"
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Message */}
<div className="mb-5">
<p className="text-sm text-amber-900 dark:text-slate-200 whitespace-pre-wrap leading-relaxed">
{cleanMessage}
</p>
</div>
{/* Action buttons */}
<div className="flex items-center gap-3">
<Button
onClick={() => handleResponse("y")}
size="sm"
className="bg-primary text-white dark:bg-primary dark:text-black px-6"
>
Yes
</Button>
<Button
onClick={() => handleResponse("n")}
size="sm"
variant="outline"
className="border-amber-300 dark:border-slate-500 text-amber-800 dark:text-slate-300 hover:bg-amber-100 dark:hover:bg-slate-700 px-6"
>
No
</Button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,136 @@
import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import { IpcClient } from "@/ipc/ipc_client";
const customLink = ({
node: _node,
...props
}: {
node?: any;
[key: string]: any;
}) => (
<a
{...props}
onClick={(e) => {
const url = props.href;
if (url) {
e.preventDefault();
IpcClient.getInstance().openExternalUrl(url);
}
}}
/>
);
export const VanillaMarkdownParser = ({ content }: { content: string }) => {
return (
<ReactMarkdown
components={{
a: customLink,
}}
>
{content}
</ReactMarkdown>
);
};
// Chat loader with human-like typing/deleting of rotating messages
function ChatLoader() {
const [currentTextIndex, setCurrentTextIndex] = useState(0);
const [displayText, setDisplayText] = useState("");
const [isDeleting, setIsDeleting] = useState(false);
const [typingSpeed, setTypingSpeed] = useState(100);
const loadingTexts = [
"Preparing your conversation... 🗨️",
"Gathering thoughts... 💭",
"Crafting the perfect response... 🎨",
"Almost there... 🚀",
"Just a moment... ⏳",
"Warming up the neural networks... 🧠",
"Connecting the dots... 🔗",
"Brewing some digital magic... ✨",
"Assembling words with care... 🔤",
"Fine-tuning the response... 🎯",
"Diving into deep thought... 🤿",
"Weaving ideas together... 🕸️",
"Sparking up the conversation... ⚡",
"Polishing the perfect reply... 💎",
];
useEffect(() => {
const currentText = loadingTexts[currentTextIndex];
const timer = window.setTimeout(() => {
if (!isDeleting) {
if (displayText.length < currentText.length) {
setDisplayText(currentText.substring(0, displayText.length + 1));
const randomSpeed = Math.random() * 50 + 30;
const isLongPause = Math.random() > 0.85;
setTypingSpeed(isLongPause ? 300 : randomSpeed);
} else {
setTypingSpeed(1500);
setIsDeleting(true);
}
} else {
if (displayText.length > 0) {
setDisplayText(currentText.substring(0, displayText.length - 1));
setTypingSpeed(30);
} else {
setIsDeleting(false);
setCurrentTextIndex((prev) => (prev + 1) % loadingTexts.length);
setTypingSpeed(500);
}
}
}, typingSpeed);
return () => window.clearTimeout(timer);
}, [displayText, isDeleting, currentTextIndex, typingSpeed]);
const renderFadingText = () => {
return displayText.split("").map((char, index) => {
const opacity = Math.min(
0.8 + (index / (displayText.length || 1)) * 0.2,
1,
);
const isEmoji = /\p{Emoji}/u.test(char);
return (
<span
key={index}
style={{ opacity }}
className={isEmoji ? "inline-block animate-emoji-bounce" : ""}
>
{char}
</span>
);
});
};
return (
<div className="flex flex-col items-center justify-center p-4">
<style>{`
@keyframes blink { from, to { opacity: 0 } 50% { opacity: 1 } }
@keyframes emoji-bounce { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(-2px) } }
@keyframes text-pulse { 0%, 100% { opacity: .85 } 50% { opacity: 1 } }
.animate-blink { animation: blink 1s steps(2, start) infinite; }
.animate-emoji-bounce { animation: emoji-bounce 1.2s ease-in-out infinite; }
.animate-text-pulse { animation: text-pulse 1.8s ease-in-out infinite; }
`}</style>
<div className="text-center animate-text-pulse">
<div className="inline-block">
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium">
{renderFadingText()}
<span className="ml-1 inline-block w-2 h-4 bg-gray-500 dark:bg-gray-400 animate-blink" />
</p>
</div>
</div>
</div>
);
}
interface LoadingBlockProps {
isStreaming?: boolean;
}
// Instead of showing raw thinking content, render the chat loader while streaming.
export function LoadingBlock({ isStreaming = false }: LoadingBlockProps) {
if (!isStreaming) return null;
return <ChatLoader />;
}

View File

@@ -0,0 +1,97 @@
import React from "react";
import { useSettings } from "@/hooks/useSettings";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { MAX_CHAT_TURNS_IN_CONTEXT } from "@/constants/settings_constants";
interface OptionInfo {
value: string;
label: string;
description: string;
}
const defaultValue = "default";
const options: OptionInfo[] = [
{
value: "2",
label: "Economy (2)",
description:
"Minimal context to reduce token usage and improve response times.",
},
{
value: defaultValue,
label: `Default (${MAX_CHAT_TURNS_IN_CONTEXT}) `,
description: "Balanced context size for most conversations.",
},
{
value: "5",
label: "Plus (5)",
description: "Slightly higher context size for detailed conversations.",
},
{
value: "10",
label: "High (10)",
description:
"Extended context for complex conversations requiring more history.",
},
{
value: "100",
label: "Max (100)",
description: "Maximum context (not recommended due to cost and speed).",
},
];
export const MaxChatTurnsSelector: React.FC = () => {
const { settings, updateSettings } = useSettings();
const handleValueChange = (value: string) => {
if (value === "default") {
updateSettings({ maxChatTurnsInContext: undefined });
} else {
const numValue = parseInt(value, 10);
updateSettings({ maxChatTurnsInContext: numValue });
}
};
// Determine the current value
const currentValue =
settings?.maxChatTurnsInContext?.toString() || defaultValue;
// Find the current option to display its description
const currentOption =
options.find((opt) => opt.value === currentValue) || options[1];
return (
<div className="space-y-1">
<div className="flex items-center gap-4">
<label
htmlFor="max-chat-turns"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Maximum number of chat turns used in context
</label>
<Select value={currentValue} onValueChange={handleValueChange}>
<SelectTrigger className="w-[180px]" id="max-chat-turns">
<SelectValue placeholder="Select turns" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{currentOption.description}
</div>
</div>
);
};

View File

@@ -0,0 +1,200 @@
import React from "react";
import { Button } from "./ui/button";
import { X, ShieldAlert } from "lucide-react";
import { toast } from "sonner";
interface McpConsentToastProps {
toastId: string | number;
serverName: string;
toolName: string;
toolDescription?: string | null;
inputPreview?: string | null;
onDecision: (decision: "accept-once" | "accept-always" | "decline") => void;
}
export function McpConsentToast({
toastId,
serverName,
toolName,
toolDescription,
inputPreview,
onDecision,
}: McpConsentToastProps) {
const handleClose = () => toast.dismiss(toastId);
const handle = (d: "accept-once" | "accept-always" | "decline") => {
onDecision(d);
toast.dismiss(toastId);
};
// Collapsible tool description state
const [isExpanded, setIsExpanded] = React.useState(false);
const [collapsedMaxHeight, setCollapsedMaxHeight] = React.useState<number>(0);
const [hasOverflow, setHasOverflow] = React.useState(false);
const descRef = React.useRef<HTMLParagraphElement | null>(null);
// Collapsible input preview state
const [isInputExpanded, setIsInputExpanded] = React.useState(false);
const [inputCollapsedMaxHeight, setInputCollapsedMaxHeight] =
React.useState<number>(0);
const [inputHasOverflow, setInputHasOverflow] = React.useState(false);
const inputRef = React.useRef<HTMLPreElement | null>(null);
React.useEffect(() => {
if (!toolDescription) {
setHasOverflow(false);
return;
}
const element = descRef.current;
if (!element) return;
const compute = () => {
const computedStyle = window.getComputedStyle(element);
const lineHeight = parseFloat(computedStyle.lineHeight || "20");
const maxLines = 4; // show first few lines by default
const maxHeightPx = Math.max(0, Math.round(lineHeight * maxLines));
setCollapsedMaxHeight(maxHeightPx);
// Overflow if full height exceeds our collapsed height
setHasOverflow(element.scrollHeight > maxHeightPx + 1);
};
// Compute initially and on resize
compute();
const onResize = () => compute();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [toolDescription]);
React.useEffect(() => {
if (!inputPreview) {
setInputHasOverflow(false);
return;
}
const element = inputRef.current;
if (!element) return;
const compute = () => {
const computedStyle = window.getComputedStyle(element);
const lineHeight = parseFloat(computedStyle.lineHeight || "16");
const maxLines = 6; // show first few lines by default
const maxHeightPx = Math.max(0, Math.round(lineHeight * maxLines));
setInputCollapsedMaxHeight(maxHeightPx);
setInputHasOverflow(element.scrollHeight > maxHeightPx + 1);
};
compute();
const onResize = () => compute();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [inputPreview]);
return (
<div className="relative bg-amber-50/95 dark:bg-slate-800/95 backdrop-blur-sm border border-amber-200 dark:border-slate-600 rounded-xl shadow-lg min-w-[420px] max-w-[560px] overflow-hidden">
<div className="p-5">
<div className="flex items-start">
<div className="flex-1">
<div className="flex items-center mb-4">
<div className="flex-shrink-0">
<div className="w-6 h-6 bg-gradient-to-br from-amber-500 to-amber-600 dark:from-amber-400 dark:to-amber-500 rounded-full flex items-center justify-center shadow-sm">
<ShieldAlert className="w-3.5 h-3.5 text-white" />
</div>
</div>
<h3 className="ml-3 text-base font-semibold text-amber-900 dark:text-amber-100">
Tool wants to run
</h3>
<button
onClick={handleClose}
className="ml-auto flex-shrink-0 p-1.5 text-amber-500 dark:text-slate-400 hover:text-amber-700 dark:hover:text-slate-200 transition-colors duration-200 rounded-md hover:bg-amber-100/50 dark:hover:bg-slate-700/50"
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-2 text-sm">
<p>
<span className="font-semibold">{toolName}</span> from
<span className="font-semibold"> {serverName}</span> requests
your consent.
</p>
{toolDescription && (
<div>
<p
ref={descRef}
className="text-muted-foreground whitespace-pre-wrap"
style={{
maxHeight: isExpanded ? "40vh" : collapsedMaxHeight,
overflow: isExpanded ? "auto" : "hidden",
}}
>
{toolDescription}
</p>
{hasOverflow && (
<button
type="button"
className="mt-1 text-xs font-medium text-amber-700 hover:underline dark:text-amber-300"
onClick={() => setIsExpanded((v) => !v)}
>
{isExpanded ? "Show less" : "Show more"}
</button>
)}
</div>
)}
{inputPreview && (
<div>
<pre
ref={inputRef}
className="bg-amber-100/60 dark:bg-slate-700/60 p-2 rounded text-xs whitespace-pre-wrap"
style={{
maxHeight: isInputExpanded
? "40vh"
: inputCollapsedMaxHeight,
overflow: isInputExpanded ? "auto" : "hidden",
}}
>
{inputPreview}
</pre>
{inputHasOverflow && (
<button
type="button"
className="mt-1 text-xs font-medium text-amber-700 hover:underline dark:text-amber-300"
onClick={() => setIsInputExpanded((v) => !v)}
>
{isInputExpanded ? "Show less" : "Show more"}
</button>
)}
</div>
)}
</div>
<div className="flex items-center gap-3 mt-4">
<Button
onClick={() => handle("accept-once")}
size="sm"
className="px-6"
>
Allow once
</Button>
<Button
onClick={() => handle("accept-always")}
size="sm"
variant="secondary"
className="px-6"
>
Always allow
</Button>
<Button
onClick={() => handle("decline")}
size="sm"
variant="outline"
className="px-6"
>
Decline
</Button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,130 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { Wrench } from "lucide-react";
import { useMcp } from "@/hooks/useMcp";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function McpToolsPicker() {
const [isOpen, setIsOpen] = useState(false);
const { servers, toolsByServer, consentsMap, setToolConsent } = useMcp();
// Removed activation toggling consent governs execution time behavior
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="outline"
className="has-[>svg]:px-2"
size="sm"
data-testid="mcp-tools-button"
>
<Wrench className="size-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Tools</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent
className="w-120 max-h-[80vh] overflow-y-auto"
align="start"
>
<div className="space-y-4">
<div>
<h3 className="font-medium">Tools (MCP)</h3>
<p className="text-sm text-muted-foreground">
Enable tools from your configured MCP servers.
</p>
</div>
{servers.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
No MCP servers configured. Configure them in Settings Tools
(MCP).
</div>
) : (
<div className="space-y-3">
{servers.map((s) => (
<div key={s.id} className="border rounded-md p-2">
<div className="flex items-center justify-between">
<div className="font-medium text-sm truncate">{s.name}</div>
{s.enabled ? (
<Badge variant="secondary">Enabled</Badge>
) : (
<Badge variant="outline">Disabled</Badge>
)}
</div>
<div className="mt-2 space-y-1">
{(toolsByServer[s.id] || []).map((t) => (
<div
key={t.name}
className="flex items-center justify-between gap-2 rounded border p-2"
>
<div className="min-w-0">
<div className="font-mono text-sm truncate">
{t.name}
</div>
{t.description && (
<div className="text-xs text-muted-foreground truncate">
{t.description}
</div>
)}
</div>
<Select
value={
consentsMap[`${s.id}:${t.name}`] ||
t.consent ||
"ask"
}
onValueChange={(v) =>
setToolConsent(s.id, t.name, v as any)
}
>
<SelectTrigger className="w-[140px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ask">Ask</SelectItem>
<SelectItem value="always">Always allow</SelectItem>
<SelectItem value="denied">Deny</SelectItem>
</SelectContent>
</Select>
</div>
))}
{(toolsByServer[s.id] || []).length === 0 && (
<div className="text-xs text-muted-foreground">
No tools discovered.
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,624 @@
import { isDyadProEnabled, type LargeLanguageModel } from "@/lib/schemas";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from "@/components/ui/dropdown-menu";
import { useEffect, useState } from "react";
import { useLocalModels } from "@/hooks/useLocalModels";
import { useLocalLMSModels } from "@/hooks/useLMStudioModels";
import { useLanguageModelsByProviders } from "@/hooks/useLanguageModelsByProviders";
import { LocalModel } from "@/ipc/ipc_types";
import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders";
import { useSettings } from "@/hooks/useSettings";
import { PriceBadge } from "@/components/PriceBadge";
import { TURBO_MODELS } from "@/ipc/shared/language_model_constants";
import { cn } from "@/lib/utils";
import { useQueryClient } from "@tanstack/react-query";
import { TOKEN_COUNT_QUERY_KEY } from "@/hooks/useCountTokens";
export function ModelPicker() {
const { settings, updateSettings } = useSettings();
const queryClient = useQueryClient();
const onModelSelect = (model: LargeLanguageModel) => {
updateSettings({ selectedModel: model });
// Invalidate token count when model changes since different models have different context windows
// (technically they have different tokenizers, but we don't keep track of that).
queryClient.invalidateQueries({ queryKey: TOKEN_COUNT_QUERY_KEY });
};
const [open, setOpen] = useState(false);
// Cloud models from providers
const { data: modelsByProviders, isLoading: modelsByProvidersLoading } =
useLanguageModelsByProviders();
const { data: providers, isLoading: providersLoading } =
useLanguageModelProviders();
const loading = modelsByProvidersLoading || providersLoading;
// Ollama Models Hook
const {
models: ollamaModels,
loading: ollamaLoading,
error: ollamaError,
loadModels: loadOllamaModels,
} = useLocalModels();
// LM Studio Models Hook
const {
models: lmStudioModels,
loading: lmStudioLoading,
error: lmStudioError,
loadModels: loadLMStudioModels,
} = useLocalLMSModels();
// Load models when the dropdown opens
useEffect(() => {
if (open) {
loadOllamaModels();
loadLMStudioModels();
}
}, [open, loadOllamaModels, loadLMStudioModels]);
// Get display name for the selected model
const getModelDisplayName = () => {
if (selectedModel.provider === "ollama") {
return (
ollamaModels.find(
(model: LocalModel) => model.modelName === selectedModel.name,
)?.displayName || selectedModel.name
);
}
if (selectedModel.provider === "lmstudio") {
return (
lmStudioModels.find(
(model: LocalModel) => model.modelName === selectedModel.name,
)?.displayName || selectedModel.name // Fallback to path if not found
);
}
// For cloud models, look up in the modelsByProviders data
if (modelsByProviders && modelsByProviders[selectedModel.provider]) {
const customFoundModel = modelsByProviders[selectedModel.provider].find(
(model) =>
model.type === "custom" && model.id === selectedModel.customModelId,
);
if (customFoundModel) {
return customFoundModel.displayName;
}
const foundModel = modelsByProviders[selectedModel.provider].find(
(model) => model.apiName === selectedModel.name,
);
if (foundModel) {
return foundModel.displayName;
}
}
// Fallback if not found
return selectedModel.name;
};
// Get auto provider models (if any)
const autoModels =
!loading && modelsByProviders && modelsByProviders["auto"]
? modelsByProviders["auto"].filter((model) => {
if (
settings &&
!isDyadProEnabled(settings) &&
["turbo", "value"].includes(model.apiName)
) {
return false;
}
if (
settings &&
isDyadProEnabled(settings) &&
model.apiName === "free"
) {
return false;
}
return true;
})
: [];
// Determine availability of local models
const hasOllamaModels =
!ollamaLoading && !ollamaError && ollamaModels.length > 0;
const hasLMStudioModels =
!lmStudioLoading && !lmStudioError && lmStudioModels.length > 0;
if (!settings) {
return null;
}
const selectedModel = settings?.selectedModel;
const modelDisplayName = getModelDisplayName();
// Split providers into primary and secondary groups (excluding auto)
const providerEntries =
!loading && modelsByProviders
? Object.entries(modelsByProviders).filter(
([providerId]) => providerId !== "auto",
)
: [];
const primaryProviders = providerEntries.filter(([providerId, models]) => {
if (models.length === 0) return false;
const provider = providers?.find((p) => p.id === providerId);
return !(provider && provider.secondary);
});
if (settings && isDyadProEnabled(settings)) {
primaryProviders.unshift(["auto", TURBO_MODELS]);
}
const secondaryProviders = providerEntries.filter(([providerId, models]) => {
if (models.length === 0) return false;
const provider = providers?.find((p) => p.id === providerId);
return !!(provider && provider.secondary);
});
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex items-center gap-2 h-8 max-w-[130px] px-1.5 text-xs-sm"
>
<span className="truncate">
{modelDisplayName === "Auto" && (
<>
<span className="text-xs text-muted-foreground">
Model:
</span>{" "}
</>
)}
{modelDisplayName}
</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>{modelDisplayName}</TooltipContent>
</Tooltip>
<DropdownMenuContent
className="w-64"
align="start"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuLabel>Cloud Models</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Cloud models - loading state */}
{loading ? (
<div className="text-xs text-center py-2 text-muted-foreground">
Loading models...
</div>
) : !modelsByProviders ||
Object.keys(modelsByProviders).length === 0 ? (
<div className="text-xs text-center py-2 text-muted-foreground">
No cloud models available
</div>
) : (
/* Cloud models loaded */
<>
{/* Auto models at top level if any */}
{autoModels.length > 0 && (
<>
{autoModels.map((model) => (
<Tooltip key={`auto-${model.apiName}`}>
<TooltipTrigger asChild>
<DropdownMenuItem
className={
selectedModel.provider === "auto" &&
selectedModel.name === model.apiName
? "bg-secondary"
: ""
}
onClick={() => {
onModelSelect({
name: model.apiName,
provider: "auto",
});
setOpen(false);
}}
>
<div className="flex justify-between items-start w-full">
<span className="flex flex-col items-start">
<span>{model.displayName}</span>
</span>
<div className="flex items-center gap-1.5">
{model.tag && (
<span
className={cn(
"text-[11px] bg-primary/10 text-primary px-1.5 py-0.5 rounded-full font-medium",
model.tagColor,
)}
>
{model.tag}
</span>
)}
</div>
</div>
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="right">
{model.description}
</TooltipContent>
</Tooltip>
))}
{Object.keys(modelsByProviders).length > 1 && (
<DropdownMenuSeparator />
)}
</>
)}
{/* Primary providers as submenus */}
{primaryProviders.map(([providerId, models]) => {
models = models.filter((model) => {
// Don't show free models if Dyad Pro is enabled because
// we will use the paid models (in Dyad Pro backend) which
// don't have the free limitations.
if (
isDyadProEnabled(settings) &&
model.apiName.endsWith(":free")
) {
return false;
}
return true;
});
const provider = providers?.find((p) => p.id === providerId);
const providerDisplayName =
provider?.id === "auto"
? "Dyad Turbo"
: (provider?.name ?? providerId);
return (
<DropdownMenuSub key={providerId}>
<DropdownMenuSubTrigger className="w-full font-normal">
<div className="flex flex-col items-start w-full">
<div className="flex items-center gap-2">
<span>{providerDisplayName}</span>
{provider?.type === "cloud" &&
!provider?.secondary &&
isDyadProEnabled(settings) && (
<span className="text-[10px] bg-gradient-to-r from-indigo-600 via-indigo-500 to-indigo-600 bg-[length:200%_100%] animate-[shimmer_5s_ease-in-out_infinite] text-white px-1.5 py-0.5 rounded-full font-medium">
Pro
</span>
)}
{provider?.type === "custom" && (
<span className="text-[10px] bg-amber-500/20 text-amber-700 px-1.5 py-0.5 rounded-full font-medium">
Custom
</span>
)}
</div>
<span className="text-xs text-muted-foreground">
{models.length} models
</span>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56 max-h-100 overflow-y-auto">
<DropdownMenuLabel>
{providerDisplayName + " Models"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{models.map((model) => (
<Tooltip key={`${providerId}-${model.apiName}`}>
<TooltipTrigger asChild>
<DropdownMenuItem
className={
selectedModel.provider === providerId &&
selectedModel.name === model.apiName
? "bg-secondary"
: ""
}
onClick={() => {
const customModelId =
model.type === "custom" ? model.id : undefined;
onModelSelect({
name: model.apiName,
provider: providerId,
customModelId,
});
setOpen(false);
}}
>
<div className="flex justify-between items-start w-full">
<span>{model.displayName}</span>
<PriceBadge dollarSigns={model.dollarSigns} />
{model.tag && (
<span className="text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded-full font-medium">
{model.tag}
</span>
)}
</div>
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="right">
{model.description}
</TooltipContent>
</Tooltip>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
);
})}
{/* Secondary providers grouped under Other AI providers */}
{secondaryProviders.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="w-full font-normal">
<div className="flex flex-col items-start">
<span>Other AI providers</span>
<span className="text-xs text-muted-foreground">
{secondaryProviders.length} providers
</span>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56">
<DropdownMenuLabel>Other AI providers</DropdownMenuLabel>
<DropdownMenuSeparator />
{secondaryProviders.map(([providerId, models]) => {
const provider = providers?.find(
(p) => p.id === providerId,
);
return (
<DropdownMenuSub key={providerId}>
<DropdownMenuSubTrigger className="w-full font-normal">
<div className="flex flex-col items-start w-full">
<div className="flex items-center gap-2">
<span>{provider?.name ?? providerId}</span>
{provider?.type === "custom" && (
<span className="text-[10px] bg-amber-500/20 text-amber-700 px-1.5 py-0.5 rounded-full font-medium">
Custom
</span>
)}
</div>
<span className="text-xs text-muted-foreground">
{models.length} models
</span>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56">
<DropdownMenuLabel>
{(provider?.name ?? providerId) + " Models"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{models.map((model) => (
<Tooltip key={`${providerId}-${model.apiName}`}>
<TooltipTrigger asChild>
<DropdownMenuItem
className={
selectedModel.provider === providerId &&
selectedModel.name === model.apiName
? "bg-secondary"
: ""
}
onClick={() => {
const customModelId =
model.type === "custom"
? model.id
: undefined;
onModelSelect({
name: model.apiName,
provider: providerId,
customModelId,
});
setOpen(false);
}}
>
<div className="flex justify-between items-start w-full">
<span>{model.displayName}</span>
{model.tag && (
<span className="text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded-full font-medium">
{model.tag}
</span>
)}
</div>
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="right">
{model.description}
</TooltipContent>
</Tooltip>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</>
)}
<DropdownMenuSeparator />
{/* Local Models Parent SubMenu */}
<DropdownMenuSub>
<DropdownMenuSubTrigger className="w-full font-normal">
<div className="flex flex-col items-start">
<span>Local models</span>
<span className="text-xs text-muted-foreground">
LM Studio, Ollama
</span>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56">
{/* Ollama Models SubMenu */}
<DropdownMenuSub>
<DropdownMenuSubTrigger
disabled={ollamaLoading && !hasOllamaModels} // Disable if loading and no models yet
className="w-full font-normal"
>
<div className="flex flex-col items-start">
<span>Ollama</span>
{ollamaLoading ? (
<span className="text-xs text-muted-foreground">
Loading...
</span>
) : ollamaError ? (
<span className="text-xs text-red-500">Error loading</span>
) : !hasOllamaModels ? (
<span className="text-xs text-muted-foreground">
None available
</span>
) : (
<span className="text-xs text-muted-foreground">
{ollamaModels.length} models
</span>
)}
</div>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56 max-h-100 overflow-y-auto">
<DropdownMenuLabel>Ollama Models</DropdownMenuLabel>
<DropdownMenuSeparator />
{ollamaLoading && ollamaModels.length === 0 ? ( // Show loading only if no models are loaded yet
<div className="text-xs text-center py-2 text-muted-foreground">
Loading models...
</div>
) : ollamaError ? (
<div className="px-2 py-1.5 text-sm text-red-600">
<div className="flex flex-col">
<span>Error loading models</span>
<span className="text-xs text-muted-foreground">
Is Ollama running?
</span>
</div>
</div>
) : !hasOllamaModels ? (
<div className="px-2 py-1.5 text-sm">
<div className="flex flex-col">
<span>No local models found</span>
<span className="text-xs text-muted-foreground">
Ensure Ollama is running and models are pulled.
</span>
</div>
</div>
) : (
ollamaModels.map((model: LocalModel) => (
<DropdownMenuItem
key={`ollama-${model.modelName}`}
className={
selectedModel.provider === "ollama" &&
selectedModel.name === model.modelName
? "bg-secondary"
: ""
}
onClick={() => {
onModelSelect({
name: model.modelName,
provider: "ollama",
});
setOpen(false);
}}
>
<div className="flex flex-col">
<span>{model.displayName}</span>
<span className="text-xs text-muted-foreground truncate">
{model.modelName}
</span>
</div>
</DropdownMenuItem>
))
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* LM Studio Models SubMenu */}
<DropdownMenuSub>
<DropdownMenuSubTrigger
disabled={lmStudioLoading && !hasLMStudioModels} // Disable if loading and no models yet
className="w-full font-normal"
>
<div className="flex flex-col items-start">
<span>LM Studio</span>
{lmStudioLoading ? (
<span className="text-xs text-muted-foreground">
Loading...
</span>
) : lmStudioError ? (
<span className="text-xs text-red-500">Error loading</span>
) : !hasLMStudioModels ? (
<span className="text-xs text-muted-foreground">
None available
</span>
) : (
<span className="text-xs text-muted-foreground">
{lmStudioModels.length} models
</span>
)}
</div>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56 max-h-100 overflow-y-auto">
<DropdownMenuLabel>LM Studio Models</DropdownMenuLabel>
<DropdownMenuSeparator />
{lmStudioLoading && lmStudioModels.length === 0 ? ( // Show loading only if no models are loaded yet
<div className="text-xs text-center py-2 text-muted-foreground">
Loading models...
</div>
) : lmStudioError ? (
<div className="px-2 py-1.5 text-sm text-red-600">
<div className="flex flex-col">
<span>Error loading models</span>
<span className="text-xs text-muted-foreground">
{lmStudioError.message} {/* Display specific error */}
</span>
</div>
</div>
) : !hasLMStudioModels ? (
<div className="px-2 py-1.5 text-sm">
<div className="flex flex-col">
<span>No loaded models found</span>
<span className="text-xs text-muted-foreground">
Ensure LM Studio is running and models are loaded.
</span>
</div>
</div>
) : (
lmStudioModels.map((model: LocalModel) => (
<DropdownMenuItem
key={`lmstudio-${model.modelName}`}
className={
selectedModel.provider === "lmstudio" &&
selectedModel.name === model.modelName
? "bg-secondary"
: ""
}
onClick={() => {
onModelSelect({
name: model.modelName,
provider: "lmstudio",
});
setOpen(false);
}}
>
<div className="flex flex-col">
{/* Display the user-friendly name */}
<span>{model.displayName}</span>
{/* Show the path as secondary info */}
<span className="text-xs text-muted-foreground truncate">
{model.modelName}
</span>
</div>
</DropdownMenuItem>
))
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,158 @@
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { IpcClient } from "@/ipc/ipc_client";
import { toast } from "sonner";
import { useSettings } from "@/hooks/useSettings";
import { useDeepLink } from "@/contexts/DeepLinkContext";
import { ExternalLink } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
import { NeonDisconnectButton } from "@/components/NeonDisconnectButton";
export function NeonConnector() {
const { settings, refreshSettings } = useSettings();
const { lastDeepLink, clearLastDeepLink } = useDeepLink();
const { isDarkMode } = useTheme();
useEffect(() => {
const handleDeepLink = async () => {
if (lastDeepLink?.type === "neon-oauth-return") {
await refreshSettings();
toast.success("Successfully connected to Neon!");
clearLastDeepLink();
}
};
handleDeepLink();
}, [lastDeepLink?.timestamp]);
if (settings?.neon?.accessToken) {
return (
<div className="flex flex-col space-y-4 p-4 border bg-white dark:bg-gray-800 max-w-100 rounded-md">
<div className="flex flex-col items-start justify-between">
<div className="flex items-center justify-between w-full">
<h2 className="text-lg font-medium pb-1">Neon Database</h2>
<Button
variant="outline"
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://console.neon.tech/",
);
}}
className="ml-2 px-2 py-1 h-8 mb-2"
style={{ display: "inline-flex", alignItems: "center" }}
asChild
>
<div className="flex items-center gap-1">
Neon
<ExternalLink className="h-3 w-3" />
</div>
</Button>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 pb-3">
You are connected to Neon Database
</p>
<NeonDisconnectButton />
</div>
</div>
);
}
return (
<div className="flex flex-col space-y-4 p-4 border bg-white dark:bg-gray-800 max-w-100 rounded-md">
<div className="flex flex-col items-start justify-between">
<h2 className="text-lg font-medium pb-1">Neon Database</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 pb-3">
Neon Database has a good free tier with backups and up to 10 projects.
</p>
<div
onClick={async () => {
if (settings?.isTestMode) {
await IpcClient.getInstance().fakeHandleNeonConnect();
} else {
await IpcClient.getInstance().openExternalUrl(
"https://oauth.dyad.sh/api/integrations/neon/login",
);
}
}}
className="w-auto h-10 cursor-pointer flex items-center justify-center px-4 py-2 rounded-md border-2 transition-colors font-medium text-sm dark:bg-gray-900 dark:border-gray-700"
data-testid="connect-neon-button"
>
<span className="mr-2">Connect to</span>
<NeonSvg isDarkMode={isDarkMode} />
</div>
</div>
</div>
);
}
function NeonSvg({
isDarkMode,
className,
}: {
isDarkMode?: boolean;
className?: string;
}) {
const textColor = isDarkMode ? "#fff" : "#000";
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="68"
height="18"
fill="none"
viewBox="0 0 102 28"
className={className}
>
<path
fill="#12FFF7"
fillRule="evenodd"
d="M0 4.828C0 2.16 2.172 0 4.851 0h18.436c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.318-6.809v8.256c0 2.4-1.955 4.345-4.367 4.345H4.851C2.172 28 0 25.839 0 23.172zm4.851-.966a.97.97 0 0 0-.97.966v18.344c0 .534.435.966.97.966h8.539c.268 0 .34-.216.34-.483v-11.07c0-2.76 3.507-3.956 5.208-1.779l5.319 6.809V4.828c0-.534.05-.966-.485-.966z"
clipRule="evenodd"
/>
<path
fill="url(#a)"
fillRule="evenodd"
d="M0 4.828C0 2.16 2.172 0 4.851 0h18.436c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.318-6.809v8.256c0 2.4-1.955 4.345-4.367 4.345H4.851C2.172 28 0 25.839 0 23.172zm4.851-.966a.97.97 0 0 0-.97.966v18.344c0 .534.435.966.97.966h8.539c.268 0 .34-.216.34-.483v-11.07c0-2.76 3.507-3.956 5.208-1.779l5.319 6.809V4.828c0-.534.05-.966-.485-.966z"
clipRule="evenodd"
/>
<path
fill="url(#b)"
fillRule="evenodd"
d="M0 4.828C0 2.16 2.172 0 4.851 0h18.436c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.318-6.809v8.256c0 2.4-1.955 4.345-4.367 4.345H4.851C2.172 28 0 25.839 0 23.172zm4.851-.966a.97.97 0 0 0-.97.966v18.344c0 .534.435.966.97.966h8.539c.268 0 .34-.216.34-.483v-11.07c0-2.76 3.507-3.956 5.208-1.779l5.319 6.809V4.828c0-.534.05-.966-.485-.966z"
clipRule="evenodd"
/>
<path
fill="#B9FFB3"
d="M23.287 0c2.679 0 4.85 2.161 4.85 4.828V20.43c0 2.758-3.507 3.955-5.208 1.778l-5.319-6.809v8.256c0 2.4-1.954 4.345-4.366 4.345a.484.484 0 0 0 .485-.483V12.584c0-2.758 3.508-3.955 5.21-1.777l5.318 6.808V.965a.97.97 0 0 0-.97-.965"
/>
<path
fill={textColor}
d="M48.112 7.432v8.032l-7.355-8.032H36.93v13.136h3.49v-8.632l8.01 8.632h3.173V7.432zM58.075 17.64v-2.326h7.815v-2.797h-7.815V10.36h9.48V7.432H54.514v13.136H67.75v-2.927zM77.028 21c4.909 0 8.098-2.552 8.098-7s-3.19-7-8.098-7c-4.91 0-8.081 2.552-8.081 7s3.172 7 8.08 7m0-3.115c-2.73 0-4.413-1.408-4.413-3.885s1.701-3.885 4.413-3.885c2.729 0 4.412 1.408 4.412 3.885s-1.683 3.885-4.412 3.885M98.508 7.432v8.032l-7.355-8.032h-3.828v13.136h3.491v-8.632l8.01 8.632H102V7.432z"
/>
<defs>
<linearGradient
id="a"
x1="28.138"
x2="3.533"
y1="28"
y2="-.12"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#B9FFB3" />
<stop offset="1" stopColor="#B9FFB3" stopOpacity="0" />
</linearGradient>
<linearGradient
id="b"
x1="28.138"
x2="11.447"
y1="28"
y2="21.476"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#1A1A1A" stopOpacity=".9" />
<stop offset="1" stopColor="#1A1A1A" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
);
}

View File

@@ -0,0 +1,38 @@
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useSettings } from "@/hooks/useSettings";
interface NeonDisconnectButtonProps {
className?: string;
}
export function NeonDisconnectButton({ className }: NeonDisconnectButtonProps) {
const { updateSettings, settings } = useSettings();
const handleDisconnect = async () => {
try {
await updateSettings({
neon: undefined,
});
toast.success("Disconnected from Neon successfully");
} catch (error) {
console.error("Failed to disconnect from Neon:", error);
toast.error("Failed to disconnect from Neon");
}
};
if (!settings?.neon?.accessToken) {
return null;
}
return (
<Button
variant="destructive"
onClick={handleDisconnect}
className={className}
size="sm"
>
Disconnect from Neon
</Button>
);
}

View File

@@ -0,0 +1,29 @@
import { useSettings } from "@/hooks/useSettings";
import { NeonDisconnectButton } from "@/components/NeonDisconnectButton";
export function NeonIntegration() {
const { settings } = useSettings();
const isConnected = !!settings?.neon?.accessToken;
if (!isConnected) {
return null;
}
return (
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Neon Integration
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Your account is connected to Neon.
</p>
</div>
<div className="flex items-center gap-2">
<NeonDisconnectButton />
</div>
</div>
);
}

View File

@@ -0,0 +1,186 @@
import { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useSettings } from "@/hooks/useSettings";
import { showError, showSuccess } from "@/lib/toast";
import { IpcClient } from "@/ipc/ipc_client";
import { FolderOpen, RotateCcw, CheckCircle, AlertCircle } from "lucide-react";
export function NodePathSelector() {
const { settings, updateSettings } = useSettings();
const [isSelectingPath, setIsSelectingPath] = useState(false);
const [nodeStatus, setNodeStatus] = useState<{
version: string | null;
isValid: boolean;
}>({
version: null,
isValid: false,
});
const [isCheckingNode, setIsCheckingNode] = useState(false);
const [systemPath, setSystemPath] = useState<string>("Loading...");
// Check Node.js status when component mounts or path changes
useEffect(() => {
checkNodeStatus();
}, [settings?.customNodePath]);
const fetchSystemPath = async () => {
try {
const debugInfo = await IpcClient.getInstance().getSystemDebugInfo();
setSystemPath(debugInfo.nodePath || "System PATH (not available)");
} catch (err) {
console.error("Failed to fetch system path:", err);
setSystemPath("System PATH (not available)");
}
};
useEffect(() => {
// Fetch system path on mount
fetchSystemPath();
}, []);
const checkNodeStatus = async () => {
if (!settings) return;
setIsCheckingNode(true);
try {
const status = await IpcClient.getInstance().getNodejsStatus();
setNodeStatus({
version: status.nodeVersion,
isValid: !!status.nodeVersion,
});
} catch (error) {
console.error("Failed to check Node.js status:", error);
setNodeStatus({ version: null, isValid: false });
} finally {
setIsCheckingNode(false);
}
};
const handleSelectNodePath = async () => {
setIsSelectingPath(true);
try {
// Call the IPC method to select folder
const result = await IpcClient.getInstance().selectNodeFolder();
if (result.path) {
// Save the custom path to settings
await updateSettings({ customNodePath: result.path });
// Update the environment PATH
await IpcClient.getInstance().reloadEnvPath();
// Recheck Node.js status
await checkNodeStatus();
showSuccess("Node.js path updated successfully");
} else if (result.path === null && result.canceled === false) {
showError(
`Could not find Node.js at the path "${result.selectedPath}"`,
);
}
} catch (error: any) {
showError(`Failed to set Node.js path: ${error.message}`);
} finally {
setIsSelectingPath(false);
}
};
const handleResetToDefault = async () => {
try {
// Clear the custom path
await updateSettings({ customNodePath: null });
// Reload environment to use system PATH
await IpcClient.getInstance().reloadEnvPath();
// Recheck Node.js status
await fetchSystemPath();
await checkNodeStatus();
showSuccess("Reset to system Node.js path");
} catch (error: any) {
showError(`Failed to reset Node.js path: ${error.message}`);
}
};
if (!settings) {
return null;
}
const currentPath = settings.customNodePath || systemPath;
const isCustomPath = !!settings.customNodePath;
return (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-sm font-medium">
Node.js Path Configuration
</Label>
<Button
onClick={handleSelectNodePath}
disabled={isSelectingPath}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<FolderOpen className="w-4 h-4" />
{isSelectingPath ? "Selecting..." : "Browse for Node.js"}
</Button>
{isCustomPath && (
<Button
onClick={handleResetToDefault}
variant="ghost"
size="sm"
className="flex items-center gap-2"
>
<RotateCcw className="w-4 h-4" />
Reset to Default
</Button>
)}
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">
{isCustomPath ? "Custom Path:" : "System PATH:"}
</span>
{isCustomPath && (
<span className="px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Custom
</span>
)}
</div>
<p className="text-sm font-mono text-gray-700 dark:text-gray-300 break-all max-h-32 overflow-y-auto">
{currentPath}
</p>
</div>
{/* Status Indicator */}
<div className="ml-3 flex items-center">
{isCheckingNode ? (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-300 border-t-blue-500" />
) : nodeStatus.isValid ? (
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CheckCircle className="w-4 h-4" />
<span className="text-xs">{nodeStatus.version}</span>
</div>
) : (
<div className="flex items-center gap-1 text-yellow-600 dark:text-yellow-400">
<AlertCircle className="w-4 h-4" />
<span className="text-xs">Not found</span>
</div>
)}
</div>
</div>
</div>
{/* Help Text */}
<div className="text-sm text-gray-500 dark:text-gray-400">
{nodeStatus.isValid ? (
<p>Node.js is properly configured and ready to use.</p>
) : (
<>
<p>
Select the folder where Node.js is installed if it's not in your
system PATH.
</p>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { IpcClient } from "@/ipc/ipc_client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ExternalLink, Database, Loader2 } from "lucide-react";
import { showSuccess, showError } from "@/lib/toast";
import { useVersions } from "@/hooks/useVersions";
interface PortalMigrateProps {
appId: number;
}
export const PortalMigrate = ({ appId }: PortalMigrateProps) => {
const [output, setOutput] = useState<string>("");
const { refreshVersions } = useVersions(appId);
const migrateMutation = useMutation({
mutationFn: async () => {
const ipcClient = IpcClient.getInstance();
return ipcClient.portalMigrateCreate({ appId });
},
onSuccess: (result) => {
setOutput(result.output);
showSuccess(
"Database migration file generated and committed successfully!",
);
refreshVersions();
},
onError: (error) => {
const errorMessage =
error instanceof Error ? error.message : String(error);
setOutput(`Error: ${errorMessage}`);
showError(errorMessage);
},
});
const handleCreateMigration = () => {
setOutput(""); // Clear previous output
migrateMutation.mutate();
};
const openDocs = () => {
const ipcClient = IpcClient.getInstance();
ipcClient.openExternalUrl(
"https://www.dyad.sh/docs/templates/portal#create-a-database-migration",
);
};
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
<Database className="w-5 h-5 text-primary" />
Portal Database Migration
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Generate a new database migration file for your Portal app.
</p>
<div className="flex items-center gap-3">
<Button
onClick={handleCreateMigration}
disabled={migrateMutation.isPending}
// className="bg-primary hover:bg-purple-700 text-white"
>
{migrateMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Generating...
</>
) : (
<>
<Database className="w-4 h-4 mr-2" />
Generate database migration
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={openDocs}
className="text-sm"
>
<ExternalLink className="w-3 h-3 mr-1" />
Docs
</Button>
</div>
{output && (
<div className="mt-4">
<div className="bg-gray-50 dark:bg-gray-900 border rounded-lg p-3">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Command Output:
</h4>
<div className="max-h-64 overflow-auto">
<pre className="text-xs text-gray-600 dark:text-gray-400 whitespace-pre-wrap font-mono">
{output}
</pre>
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,20 @@
import React from "react";
export function PriceBadge({
dollarSigns,
}: {
dollarSigns: number | undefined;
}) {
if (dollarSigns === undefined || dollarSigns === null) return null;
const label = dollarSigns === 0 ? "Free" : "$".repeat(dollarSigns);
const className =
dollarSigns === 0
? "text-[10px] text-primary border border-primary px-1.5 py-0.5 rounded-full font-medium"
: "text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded-full font-medium";
return <span className={className}>{label}</span>;
}
export default PriceBadge;

View File

@@ -0,0 +1,228 @@
// @ts-ignore
import openAiLogo from "../../assets/ai-logos/openai-logo.svg";
// @ts-ignore
import googleLogo from "../../assets/ai-logos/google-logo.svg";
// @ts-ignore
import anthropicLogo from "../../assets/ai-logos/anthropic-logo.svg";
import { IpcClient } from "@/ipc/ipc_client";
import { useState } from "react";
import { KeyRound } from "lucide-react";
import { useSettings } from "@/hooks/useSettings";
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
import { Button } from "./ui/button";
export function ProBanner() {
const { settings } = useSettings();
const { userBudget } = useUserBudgetInfo();
const [selectedBanner] = useState<"ai" | "smart" | "turbo">(() => {
const options = ["ai", "smart", "turbo"] as const;
return options[Math.floor(Math.random() * options.length)];
});
if (settings?.enableDyadPro || userBudget) {
return (
<div className="mt-6 max-w-2xl mx-auto">
<ManageDyadProButton />
</div>
);
}
return (
<div className="mt-6 max-w-2xl mx-auto">
{selectedBanner === "ai" ? (
<AiAccessBanner />
) : selectedBanner === "smart" ? (
<SmartContextBanner />
) : (
<TurboBanner />
)}
<SetupDyadProButton />
</div>
);
}
export function ManageDyadProButton() {
return (
<Button
variant="outline"
size="lg"
className="w-full mt-4 bg-(--background-lighter) text-primary"
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://academy.dyad.sh/subscription",
);
}}
>
<KeyRound aria-hidden="true" />
Manage Dyad Pro subscription
</Button>
);
}
export function SetupDyadProButton() {
return (
<Button
variant="outline"
size="lg"
className="w-full mt-4 bg-(--background-lighter) text-primary"
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://academy.dyad.sh/settings",
);
}}
>
<KeyRound aria-hidden="true" />
Already have Dyad Pro? Add your key
</Button>
);
}
export function AiAccessBanner() {
return (
<div
className="w-full py-2 sm:py-2.5 md:py-3 rounded-lg bg-gradient-to-br from-white via-indigo-50 to-sky-100 dark:from-indigo-700 dark:via-indigo-700 dark:to-indigo-900 flex items-center justify-center relative overflow-hidden ring-1 ring-inset ring-black/5 dark:ring-white/10 shadow-sm cursor-pointer transition-all duration-200 hover:shadow-md hover:-translate-y-[1px]"
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://www.dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=in-app-banner-ai-access",
);
}}
>
<div
className="absolute inset-0 z-0 bg-gradient-to-tr from-white/60 via-transparent to-transparent pointer-events-none dark:from-white/10"
aria-hidden="true"
/>
<div className="absolute inset-0 z-0 pointer-events-none dark:hidden">
<div className="absolute -top-8 -left-6 h-40 w-40 rounded-full blur-2xl bg-violet-200/40" />
<div className="absolute -bottom-10 -right-6 h-48 w-48 rounded-full blur-3xl bg-sky-200/40" />
</div>
<div className="relative z-10 text-center flex flex-col items-center gap-0.5 sm:gap-1 md:gap-1.5 px-4 md:px-6 pr-6 md:pr-8">
<div className="mt-0.5 sm:mt-1 flex items-center gap-2 sm:gap-3 justify-center">
<div className="text-xl font-semibold tracking-tight text-indigo-900 dark:text-indigo-100">
Access leading AI models with one plan
</div>
<button
type="button"
aria-label="Subscribe to Dyad Pro"
className="inline-flex items-center rounded-md bg-white/90 text-indigo-800 hover:bg-white shadow px-3 py-1.5 text-xs sm:text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-white/50"
>
Get Dyad Pro
</button>
</div>
<div className="mt-1.5 sm:mt-2 grid grid-cols-3 gap-6 md:gap-8 items-center justify-items-center opacity-90">
<div className="flex items-center justify-center">
<img
src={openAiLogo}
alt="OpenAI"
width={96}
height={28}
className="h-4 md:h-5 w-auto dark:invert"
/>
</div>
<div className="flex items-center justify-center">
<img
src={googleLogo}
alt="Google"
width={110}
height={30}
className="h-4 md:h-5 w-auto"
/>
</div>
<div className="flex items-center justify-center">
<img
src={anthropicLogo}
alt="Anthropic"
width={110}
height={30}
className="h-3 w-auto dark:invert"
/>
</div>
</div>
</div>
</div>
);
}
export function SmartContextBanner() {
return (
<div
className="w-full py-2 sm:py-2.5 md:py-3 rounded-lg bg-gradient-to-br from-emerald-50 via-emerald-100 to-emerald-200 dark:from-emerald-700 dark:via-emerald-700 dark:to-emerald-900 flex items-center justify-center relative overflow-hidden ring-1 ring-inset ring-emerald-900/10 dark:ring-white/10 shadow-sm cursor-pointer transition-all duration-200 hover:shadow-md hover:-translate-y-[1px]"
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://www.dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=in-app-banner-smart-context",
);
}}
>
<div
className="absolute inset-0 z-0 bg-gradient-to-tr from-white/60 via-transparent to-transparent pointer-events-none dark:from-white/10"
aria-hidden="true"
/>
<div className="absolute inset-0 z-0 pointer-events-none dark:hidden">
<div className="absolute -top-10 -left-8 h-44 w-44 rounded-full blur-2xl bg-emerald-200/50" />
<div className="absolute -bottom-12 -right-8 h-56 w-56 rounded-full blur-3xl bg-teal-200/50" />
</div>
<div className="relative z-10 px-4 md:px-6 pr-6 md:pr-8">
<div className="mt-0.5 sm:mt-1 flex items-center gap-2 sm:gap-3 justify-center">
<div className="flex flex-col items-center text-center">
<div className="text-xl font-semibold tracking-tight text-emerald-900 dark:text-emerald-100">
Up to 5x cheaper
</div>
<div className="text-sm sm:text-base mt-1 text-emerald-700 dark:text-emerald-200/80">
by using Smart Context
</div>
</div>
<button
type="button"
aria-label="Get Dyad Pro"
className="inline-flex items-center rounded-md bg-white/90 text-emerald-800 hover:bg-white shadow px-3 py-1.5 text-xs sm:text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-white/50"
>
Get Dyad Pro
</button>
</div>
</div>
</div>
);
}
export function TurboBanner() {
return (
<div
className="w-full py-2 sm:py-2.5 md:py-3 rounded-lg bg-gradient-to-br from-rose-50 via-rose-100 to-rose-200 dark:from-rose-800 dark:via-fuchsia-800 dark:to-rose-800 flex items-center justify-center relative overflow-hidden ring-1 ring-inset ring-rose-900/10 dark:ring-white/5 shadow-sm cursor-pointer transition-all duration-200 hover:shadow-md hover:-translate-y-[1px]"
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://www.dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=in-app-banner-turbo",
);
}}
>
<div
className="absolute inset-0 z-0 bg-gradient-to-tr from-white/60 via-transparent to-transparent pointer-events-none dark:from-white/10"
aria-hidden="true"
/>
<div className="absolute inset-0 z-0 pointer-events-none dark:hidden">
<div className="absolute -top-10 -left-8 h-44 w-44 rounded-full blur-2xl bg-rose-200/50" />
<div className="absolute -bottom-12 -right-8 h-56 w-56 rounded-full blur-3xl bg-fuchsia-200/50" />
</div>
<div className="relative z-10 px-4 md:px-6 pr-6 md:pr-8">
<div className="mt-0.5 sm:mt-1 flex items-center gap-2 sm:gap-3 justify-center">
<div className="flex flex-col items-center text-center">
<div className="text-xl font-semibold tracking-tight text-rose-900 dark:text-rose-100">
Generate code 410x faster
</div>
<div className="text-sm sm:text-base mt-1 text-rose-700 dark:text-rose-200/80">
with Turbo Models & Turbo Edits
</div>
</div>
<button
type="button"
aria-label="Get Dyad Pro"
className="inline-flex items-center rounded-md bg-white/90 text-rose-800 hover:bg-white shadow px-3 py-1.5 text-xs sm:text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-white/50"
>
Get Dyad Pro
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,396 @@
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Sparkles, Info } from "lucide-react";
import { useSettings } from "@/hooks/useSettings";
import { IpcClient } from "@/ipc/ipc_client";
import { hasDyadProKey, type UserSettings } from "@/lib/schemas";
export function ProModeSelector() {
const { settings, updateSettings } = useSettings();
const toggleWebSearch = () => {
updateSettings({
enableProWebSearch: !settings?.enableProWebSearch,
});
};
const handleTurboEditsChange = (newValue: "off" | "v1" | "v2") => {
updateSettings({
enableProLazyEditsMode: newValue !== "off",
proLazyEditsMode: newValue,
});
};
const handleSmartContextChange = (newValue: "off" | "deep" | "balanced") => {
if (newValue === "off") {
updateSettings({
enableProSmartFilesContextMode: false,
proSmartContextOption: undefined,
});
} else if (newValue === "deep") {
updateSettings({
enableProSmartFilesContextMode: true,
proSmartContextOption: "deep",
});
} else if (newValue === "balanced") {
updateSettings({
enableProSmartFilesContextMode: true,
proSmartContextOption: "balanced",
});
}
};
const toggleProEnabled = () => {
updateSettings({
enableDyadPro: !settings?.enableDyadPro,
});
};
const hasProKey = settings ? hasDyadProKey(settings) : false;
const proModeTogglable = hasProKey && Boolean(settings?.enableDyadPro);
return (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="has-[>svg]:px-1.5 flex items-center gap-1.5 h-8 border-primary/50 hover:bg-primary/10 font-medium shadow-sm shadow-primary/10 transition-all hover:shadow-md hover:shadow-primary/15"
>
<Sparkles className="h-4 w-4 text-primary" />
<span className="text-primary font-medium text-xs-sm">Pro</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Configure Dyad Pro settings</TooltipContent>
</Tooltip>
<PopoverContent className="w-80 border-primary/20">
<div className="space-y-4">
<div className="space-y-1">
<h4 className="font-medium flex items-center gap-1.5">
<Sparkles className="h-4 w-4 text-primary" />
<span className="text-primary font-medium">Dyad Pro</span>
</h4>
<div className="h-px bg-gradient-to-r from-primary/50 via-primary/20 to-transparent" />
</div>
{!hasProKey && (
<div className="text-sm text-center text-muted-foreground">
<Tooltip>
<TooltipTrigger asChild>
<a
className="inline-flex items-center justify-center gap-2 rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-sm font-medium text-primary shadow-sm transition-colors hover:bg-primary/20 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-pointer"
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://dyad.sh/pro#ai",
);
}}
>
Unlock Pro modes
</a>
</TooltipTrigger>
<TooltipContent>
Visit dyad.sh/pro to unlock Pro features
</TooltipContent>
</Tooltip>
</div>
)}
<div className="flex flex-col gap-5">
<SelectorRow
id="pro-enabled"
label="Enable Dyad Pro"
tooltip="Uses Dyad Pro AI credits for the main AI model and Pro modes."
isTogglable={hasProKey}
settingEnabled={Boolean(settings?.enableDyadPro)}
toggle={toggleProEnabled}
/>
<SelectorRow
id="web-search"
label="Web Access"
tooltip="Allows Dyad to access the web (e.g. search for information)"
isTogglable={proModeTogglable}
settingEnabled={Boolean(settings?.enableProWebSearch)}
toggle={toggleWebSearch}
/>
<TurboEditsSelector
isTogglable={proModeTogglable}
settings={settings}
onValueChange={handleTurboEditsChange}
/>
<SmartContextSelector
isTogglable={proModeTogglable}
settings={settings}
onValueChange={handleSmartContextChange}
/>
</div>
</div>
</PopoverContent>
</Popover>
);
}
function SelectorRow({
id,
label,
tooltip,
isTogglable,
settingEnabled,
toggle,
}: {
id: string;
label: string;
tooltip: string;
isTogglable: boolean;
settingEnabled: boolean;
toggle: () => void;
}) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Label
htmlFor={id}
className={!isTogglable ? "text-muted-foreground/50" : ""}
>
{label}
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info
className={`h-4 w-4 cursor-help ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"}`}
/>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-72">
{tooltip}
</TooltipContent>
</Tooltip>
</div>
<Switch
id={id}
checked={isTogglable ? settingEnabled : false}
onCheckedChange={toggle}
disabled={!isTogglable}
/>
</div>
);
}
function TurboEditsSelector({
isTogglable,
settings,
onValueChange,
}: {
isTogglable: boolean;
settings: UserSettings | null;
onValueChange: (value: "off" | "v1" | "v2") => void;
}) {
// Determine current value based on settings
const getCurrentValue = (): "off" | "v1" | "v2" => {
if (!settings?.enableProLazyEditsMode) {
return "off";
}
if (settings?.proLazyEditsMode === "v1") {
return "v1";
}
if (settings?.proLazyEditsMode === "v2") {
return "v2";
}
// Keep in sync with getModelClient in get_model_client.ts
// If enabled but no option set (undefined/falsey), it's v1
return "v1";
};
const currentValue = getCurrentValue();
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className={!isTogglable ? "text-muted-foreground/50" : ""}>
Turbo Edits
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info
className={`h-4 w-4 cursor-help ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"}`}
/>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-72">
Edits files efficiently without full rewrites.
<br />
<ul className="list-disc ml-4">
<li>
<b>Classic:</b> Uses a smaller model to complete edits.
</li>
<li>
<b>Search & replace:</b> Find and replaces specific text blocks.
</li>
</ul>
</TooltipContent>
</Tooltip>
</div>
<div
className="inline-flex rounded-md border border-input"
data-testid="turbo-edits-selector"
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={currentValue === "off" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("off")}
disabled={!isTogglable}
className="rounded-r-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
>
Off
</Button>
</TooltipTrigger>
<TooltipContent>Disable Turbo Edits</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={currentValue === "v1" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("v1")}
disabled={!isTogglable}
className="rounded-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
>
Classic
</Button>
</TooltipTrigger>
<TooltipContent>
Uses a smaller model to complete edits
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={currentValue === "v2" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("v2")}
disabled={!isTogglable}
className="rounded-l-none h-8 px-3 text-xs flex-shrink-0"
>
Search & replace
</Button>
</TooltipTrigger>
<TooltipContent>
Find and replaces specific text blocks
</TooltipContent>
</Tooltip>
</div>
</div>
);
}
function SmartContextSelector({
isTogglable,
settings,
onValueChange,
}: {
isTogglable: boolean;
settings: UserSettings | null;
onValueChange: (value: "off" | "balanced" | "deep") => void;
}) {
// Determine current value based on settings
const getCurrentValue = (): "off" | "conservative" | "balanced" | "deep" => {
if (!settings?.enableProSmartFilesContextMode) {
return "off";
}
if (settings?.proSmartContextOption === "deep") {
return "deep";
}
if (settings?.proSmartContextOption === "balanced") {
return "balanced";
}
// Keep logic in sync with isDeepContextEnabled in chat_stream_handlers.ts
return "deep";
};
const currentValue = getCurrentValue();
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className={!isTogglable ? "text-muted-foreground/50" : ""}>
Smart Context
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info
className={`h-4 w-4 cursor-help ${!isTogglable ? "text-muted-foreground/50" : "text-muted-foreground"}`}
/>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-72">
Selects the most relevant files as context to save credits working
on large codebases.
</TooltipContent>
</Tooltip>
</div>
<div
className="inline-flex rounded-md border border-input"
data-testid="smart-context-selector"
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={currentValue === "off" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("off")}
disabled={!isTogglable}
className="rounded-r-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
>
Off
</Button>
</TooltipTrigger>
<TooltipContent>Disable Smart Context</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={currentValue === "balanced" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("balanced")}
disabled={!isTogglable}
className="rounded-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
>
Balanced
</Button>
</TooltipTrigger>
<TooltipContent>
Selects most relevant files with balanced context size
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={currentValue === "deep" ? "default" : "ghost"}
size="sm"
onClick={() => onValueChange("deep")}
disabled={!isTogglable}
className="rounded-l-none h-8 px-3 text-xs flex-shrink-0"
>
Deep
</Button>
</TooltipTrigger>
<TooltipContent>
<b>Experimental:</b> Keeps full conversation history for maximum
context and cache-optimized to control costs
</TooltipContent>
</Tooltip>
</div>
</div>
);
}

View File

@@ -0,0 +1,242 @@
import {
Card,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { useNavigate } from "@tanstack/react-router";
import { providerSettingsRoute } from "@/routes/settings/providers/$provider";
import type { LanguageModelProvider } from "@/ipc/ipc_types";
import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders";
import { useCustomLanguageModelProvider } from "@/hooks/useCustomLanguageModelProvider";
import { GiftIcon, PlusIcon, Trash2, Edit } from "lucide-react";
import { Skeleton } from "./ui/skeleton";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { AlertTriangle } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CreateCustomProviderDialog } from "./CreateCustomProviderDialog";
export function ProviderSettingsGrid() {
const navigate = useNavigate();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingProvider, setEditingProvider] =
useState<LanguageModelProvider | null>(null);
const [providerToDelete, setProviderToDelete] = useState<string | null>(null);
const {
data: providers,
isLoading,
error,
isProviderSetup,
refetch,
} = useLanguageModelProviders();
const { deleteProvider, isDeleting } = useCustomLanguageModelProvider();
const handleProviderClick = (providerId: string) => {
navigate({
to: providerSettingsRoute.id,
params: { provider: providerId },
});
};
const handleDeleteProvider = async () => {
if (providerToDelete) {
await deleteProvider(providerToDelete);
setProviderToDelete(null);
refetch();
}
};
const handleEditProvider = (provider: LanguageModelProvider) => {
setEditingProvider(provider);
setIsDialogOpen(true);
};
if (isLoading) {
return (
<div className="p-6">
<h2 className="text-lg font-medium mb-6">AI Providers</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5].map((i) => (
<Card key={i} className="border-border">
<CardHeader className="p-4">
<Skeleton className="h-6 w-3/4 mb-2" />
<Skeleton className="h-4 w-1/2" />
</CardHeader>
</Card>
))}
</div>
</div>
);
}
if (error) {
return (
<div className="p-6">
<h2 className="text-lg font-medium mb-6">AI Providers</h2>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Failed to load AI providers: {error.message}
</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="p-6">
<h2 className="text-lg font-medium mb-6">AI Providers</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{providers
?.filter((p) => p.type !== "local")
.map((provider: LanguageModelProvider) => {
const isCustom = provider.type === "custom";
return (
<Card
key={provider.id}
className="relative transition-all hover:shadow-md border-border"
>
<CardHeader
className="p-4 cursor-pointer"
onClick={() => handleProviderClick(provider.id)}
>
{isCustom && (
<div
className="flex items-center justify-end"
onClick={(e) => e.stopPropagation()}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
data-testid="edit-custom-provider"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:bg-muted rounded-md"
onClick={() => handleEditProvider(provider)}
>
<Edit className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit Provider</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
data-testid="delete-custom-provider"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10 rounded-md"
onClick={() => setProviderToDelete(provider.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete Provider</TooltipContent>
</Tooltip>
</div>
)}
<CardTitle className="text-lg font-medium mb-2">
{provider.name}
{isProviderSetup(provider.id) ? (
<span className="ml-3 text-sm font-medium text-green-500 bg-green-50 dark:bg-green-900/30 border border-green-500/50 dark:border-green-500/50 px-2 py-1 rounded-full">
Ready
</span>
) : (
<span className="text-sm text-gray-500 bg-gray-50 dark:bg-gray-900 dark:text-gray-300 px-2 py-1 rounded-full">
Needs Setup
</span>
)}
</CardTitle>
<CardDescription>
{provider.hasFreeTier && (
<span className="text-blue-600 mt-2 dark:text-blue-400 text-sm font-medium bg-blue-100 dark:bg-blue-900/30 px-2 py-1 rounded-full inline-flex items-center">
<GiftIcon className="w-4 h-4 mr-1" />
Free tier available
</span>
)}
</CardDescription>
</CardHeader>
</Card>
);
})}
{/* Add custom provider button */}
<Card
className="cursor-pointer transition-all hover:shadow-md border-border border-dashed hover:border-primary/70"
onClick={() => setIsDialogOpen(true)}
>
<CardHeader className="p-4 flex flex-col items-center justify-center h-full">
<PlusIcon className="h-8 w-8 text-muted-foreground mb-2" />
<CardTitle className="text-lg font-medium text-center">
Add custom provider
</CardTitle>
<CardDescription className="text-center">
Connect to a custom LLM API endpoint
</CardDescription>
</CardHeader>
</Card>
</div>
<CreateCustomProviderDialog
isOpen={isDialogOpen}
onClose={() => {
setIsDialogOpen(false);
setEditingProvider(null);
}}
onSuccess={() => {
setIsDialogOpen(false);
refetch();
setEditingProvider(null);
}}
editingProvider={editingProvider}
/>
<AlertDialog
open={!!providerToDelete}
onOpenChange={(open) => !open && setProviderToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Custom Provider</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this custom provider and all its
associated models. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteProvider}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete Provider"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { useSettings } from "@/hooks/useSettings";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import { IpcClient } from "@/ipc/ipc_client";
import type { ReleaseChannel } from "@/lib/schemas";
export function ReleaseChannelSelector() {
const { settings, updateSettings } = useSettings();
if (!settings) {
return null;
}
const handleReleaseChannelChange = (value: ReleaseChannel) => {
updateSettings({ releaseChannel: value });
if (value === "stable") {
toast("Using Stable release channel", {
description:
"You'll stay on your current version until a newer stable release is available, or you can manually downgrade now.",
action: {
label: "Download Stable",
onClick: () => {
IpcClient.getInstance().openExternalUrl("https://dyad.sh/download");
},
},
});
} else {
toast("Using Beta release channel", {
description:
"You will need to restart Dyad for your settings to take effect.",
action: {
label: "Restart Dyad",
onClick: () => {
IpcClient.getInstance().restartDyad();
},
},
});
}
};
return (
<div className="space-y-1">
<div className="flex items-center space-x-2">
<label
htmlFor="release-channel"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Release Channel
</label>
<Select
value={settings.releaseChannel}
onValueChange={handleReleaseChannelChange}
>
<SelectTrigger className="w-32" id="release-channel">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stable">Stable</SelectItem>
<SelectItem value="beta">Beta</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
<p>Stable is recommended for most users. </p>
<p>Beta receives more frequent updates but may have more bugs.</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useSettings } from "@/hooks/useSettings";
import { showError } from "@/lib/toast";
import { IpcClient } from "@/ipc/ipc_client";
export function RuntimeModeSelector() {
const { settings, updateSettings } = useSettings();
if (!settings) {
return null;
}
const isDockerMode = settings?.runtimeMode2 === "docker";
const handleRuntimeModeChange = async (value: "host" | "docker") => {
try {
await updateSettings({ runtimeMode2: value });
} catch (error: any) {
showError(`Failed to update runtime mode: ${error.message}`);
}
};
return (
<div className="space-y-2">
<div className="space-y-1">
<div className="flex items-center space-x-2">
<Label className="text-sm font-medium" htmlFor="runtime-mode">
Runtime Mode
</Label>
<Select
value={settings.runtimeMode2 ?? "host"}
onValueChange={handleRuntimeModeChange}
>
<SelectTrigger className="w-48" id="runtime-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="host">Local (default)</SelectItem>
<SelectItem value="docker">Docker (experimental)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Choose whether to run apps directly on the local machine or in Docker
containers
</div>
</div>
{isDockerMode && (
<div className="text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 p-2 rounded">
Docker mode is <b>experimental</b> and requires{" "}
<button
type="button"
className="underline font-medium cursor-pointer"
onClick={() =>
IpcClient.getInstance().openExternalUrl(
"https://www.docker.com/products/docker-desktop/",
)
}
>
Docker Desktop
</button>{" "}
to be installed and running
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { DialogTitle } from "@radix-ui/react-dialog";
import { Dialog, DialogContent, DialogHeader } from "./ui/dialog";
import { Button } from "./ui/button";
import { BugIcon } from "lucide-react";
interface ScreenshotSuccessDialogProps {
isOpen: boolean;
onClose: () => void;
handleReportBug: () => Promise<void>;
isLoading: boolean;
}
export function ScreenshotSuccessDialog({
isOpen,
onClose,
handleReportBug,
isLoading,
}: ScreenshotSuccessDialogProps) {
const handleSubmit = async () => {
await handleReportBug();
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Screenshot captured to clipboard! Please paste in GitHub issue.
</DialogTitle>
</DialogHeader>
<Button
variant="default"
onClick={handleSubmit}
className="w-full py-6 border-primary/50 shadow-sm shadow-primary/10 transition-all hover:shadow-md hover:shadow-primary/15"
>
<BugIcon className="mr-2 h-5 w-5" />{" "}
{isLoading ? "Preparing Report..." : "Create GitHub issue"}
</Button>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,83 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { useEffect } from "react";
import { useScrollAndNavigateTo } from "@/hooks/useScrollAndNavigateTo";
import { useAtom } from "jotai";
import { activeSettingsSectionAtom } from "@/atoms/viewAtoms";
const SETTINGS_SECTIONS = [
{ id: "general-settings", label: "General" },
{ id: "workflow-settings", label: "Workflow" },
{ id: "ai-settings", label: "AI" },
{ id: "provider-settings", label: "Model Providers" },
{ id: "telemetry", label: "Telemetry" },
{ id: "integrations", label: "Integrations" },
{ id: "tools-mcp", label: "Tools (MCP)" },
{ id: "experiments", label: "Experiments" },
{ id: "danger-zone", label: "Danger Zone" },
];
export function SettingsList({ show }: { show: boolean }) {
const [activeSection, setActiveSection] = useAtom(activeSettingsSectionAtom);
const scrollAndNavigateTo = useScrollAndNavigateTo("/settings", {
behavior: "smooth",
block: "start",
});
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveSection(entry.target.id);
return;
}
}
},
{ rootMargin: "-20% 0px -80% 0px", threshold: 0 },
);
for (const section of SETTINGS_SECTIONS) {
const el = document.getElementById(section.id);
if (el) {
observer.observe(el);
}
}
return () => {
observer.disconnect();
};
}, []);
if (!show) {
return null;
}
const handleScrollAndNavigateTo = scrollAndNavigateTo;
return (
<div className="flex flex-col h-full">
<div className="flex-shrink-0 p-4">
<h2 className="text-lg font-semibold tracking-tight">Settings</h2>
</div>
<ScrollArea className="flex-grow">
<div className="space-y-1 p-4 pt-0">
{SETTINGS_SECTIONS.map((section) => (
<button
key={section.id}
onClick={() => handleScrollAndNavigateTo(section.id)}
className={cn(
"w-full text-left px-3 py-2 rounded-md text-sm transition-colors",
activeSection === section.id
? "bg-sidebar-accent text-sidebar-accent-foreground font-semibold"
: "hover:bg-sidebar-accent",
)}
>
{section.label}
</button>
))}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,488 @@
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
</>
}
/>
);
};

View File

@@ -0,0 +1,109 @@
import { ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { ReactNode } from "react";
type SetupProviderVariant = "google" | "openrouter" | "dyad";
export function SetupProviderCard({
variant,
title,
subtitle,
chip,
leadingIcon,
onClick,
tabIndex = 0,
className,
}: {
variant: SetupProviderVariant;
title: string;
subtitle?: string;
chip?: ReactNode;
leadingIcon: ReactNode;
onClick: () => void;
tabIndex?: number;
className?: string;
}) {
const styles = getVariantStyles(variant);
return (
<div
className={cn(
"p-3 border rounded-lg cursor-pointer transition-colors relative",
styles.container,
className,
)}
onClick={onClick}
role="button"
tabIndex={tabIndex}
>
{chip && (
<div
className={cn(
"absolute top-2 right-2 px-2 py-1 rounded-full text-xs font-semibold",
styles.subtitleColor,
"bg-white/80 dark:bg-black/20 backdrop-blur-sm",
)}
>
{chip}
</div>
)}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className={cn("p-1.5 rounded-full", styles.iconWrapper)}>
{leadingIcon}
</div>
<div>
<h4 className={cn("font-medium text-[15px]", styles.titleColor)}>
{title}
</h4>
{subtitle ? (
<div
className={cn(
"text-sm flex items-center gap-1",
styles.subtitleColor,
)}
>
{subtitle}
</div>
) : null}
</div>
</div>
<ChevronRight className={cn("w-4 h-4", styles.chevronColor)} />
</div>
</div>
);
}
function getVariantStyles(variant: SetupProviderVariant) {
switch (variant) {
case "google":
return {
container:
"bg-blue-50 dark:bg-blue-900/50 border-blue-200 dark:border-blue-700 hover:bg-blue-100 dark:hover:bg-blue-900/70",
iconWrapper: "bg-blue-100 dark:bg-blue-800",
titleColor: "text-blue-800 dark:text-blue-300",
subtitleColor: "text-blue-600 dark:text-blue-400",
chevronColor: "text-blue-600 dark:text-blue-400",
} as const;
case "openrouter":
return {
container:
"bg-teal-50 dark:bg-teal-900/50 border-teal-200 dark:border-teal-700 hover:bg-teal-100 dark:hover:bg-teal-900/70",
iconWrapper: "bg-teal-100 dark:bg-teal-800",
titleColor: "text-teal-800 dark:text-teal-300",
subtitleColor: "text-teal-600 dark:text-teal-400",
chevronColor: "text-teal-600 dark:text-teal-400",
} as const;
case "dyad":
return {
container:
"bg-primary/10 border-primary/50 dark:bg-violet-800/50 dark:border-violet-700 hover:bg-violet-100 dark:hover:bg-violet-900/70",
iconWrapper: "bg-primary/5 dark:bg-violet-800",
titleColor: "text-violet-800 dark:text-violet-300",
subtitleColor: "text-violet-600 dark:text-violet-400",
chevronColor: "text-violet-600 dark:text-violet-400",
} as const;
}
}
export default SetupProviderCard;

View File

@@ -0,0 +1,284 @@
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { IpcClient } from "@/ipc/ipc_client";
import { toast } from "sonner";
import { useSettings } from "@/hooks/useSettings";
import { useSupabase } from "@/hooks/useSupabase";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useLoadApp } from "@/hooks/useLoadApp";
import { useDeepLink } from "@/contexts/DeepLinkContext";
// @ts-ignore
import supabaseLogoLight from "../../assets/supabase/supabase-logo-wordmark--light.svg";
// @ts-ignore
import supabaseLogoDark from "../../assets/supabase/supabase-logo-wordmark--dark.svg";
// @ts-ignore
import connectSupabaseDark from "../../assets/supabase/connect-supabase-dark.svg";
// @ts-ignore
import connectSupabaseLight from "../../assets/supabase/connect-supabase-light.svg";
import { ExternalLink } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
export function SupabaseConnector({ appId }: { appId: number }) {
const { settings, refreshSettings } = useSettings();
const { app, refreshApp } = useLoadApp(appId);
const { lastDeepLink, clearLastDeepLink } = useDeepLink();
const { isDarkMode } = useTheme();
useEffect(() => {
const handleDeepLink = async () => {
if (lastDeepLink?.type === "supabase-oauth-return") {
await refreshSettings();
await refreshApp();
clearLastDeepLink();
}
};
handleDeepLink();
}, [lastDeepLink?.timestamp]);
const {
projects,
loading,
error,
loadProjects,
branches,
loadBranches,
setAppProject,
unsetAppProject,
} = useSupabase();
const currentProjectId = app?.supabaseProjectId;
useEffect(() => {
// Load projects when the component mounts and user is connected
if (settings?.supabase?.accessToken) {
loadProjects();
}
}, [settings?.supabase?.accessToken, loadProjects]);
const handleProjectSelect = async (projectId: string) => {
try {
await setAppProject({ projectId, appId });
toast.success("Project connected to app successfully");
await refreshApp();
} catch (error) {
toast.error("Failed to connect project to app: " + error);
}
};
const projectIdForBranches =
app?.supabaseParentProjectId || app?.supabaseProjectId;
useEffect(() => {
if (projectIdForBranches) {
loadBranches(projectIdForBranches);
}
}, [projectIdForBranches, loadBranches]);
const handleUnsetProject = async () => {
try {
await unsetAppProject(appId);
toast.success("Project disconnected from app successfully");
await refreshApp();
} catch (error) {
console.error("Failed to disconnect project:", error);
toast.error("Failed to disconnect project from app");
}
};
if (settings?.supabase?.accessToken) {
if (app?.supabaseProjectName) {
return (
<Card className="mt-1">
<CardHeader>
<CardTitle className="flex items-center justify-between">
Supabase Project{" "}
<Button
variant="outline"
onClick={() => {
IpcClient.getInstance().openExternalUrl(
`https://supabase.com/dashboard/project/${app.supabaseProjectId}`,
);
}}
className="ml-2 px-2 py-1"
style={{ display: "inline-flex", alignItems: "center" }}
asChild
>
<div className="flex items-center gap-2">
<img
src={isDarkMode ? supabaseLogoDark : supabaseLogoLight}
alt="Supabase Logo"
style={{ height: 20, width: "auto", marginRight: 4 }}
/>
<ExternalLink className="h-4 w-4" />
</div>
</Button>
</CardTitle>
<CardDescription>
This app is connected to project: {app.supabaseProjectName}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="supabase-branch-select">Database Branch</Label>
<Select
value={app.supabaseProjectId || ""}
onValueChange={async (supabaseBranchProjectId) => {
try {
const branch = branches.find(
(b) => b.projectRef === supabaseBranchProjectId,
);
if (!branch) {
throw new Error("Branch not found");
}
await setAppProject({
projectId: branch.projectRef,
parentProjectId: branch.parentProjectRef,
appId,
});
toast.success("Branch selected");
await refreshApp();
} catch (error) {
toast.error("Failed to set branch: " + error);
}
}}
disabled={loading}
>
<SelectTrigger
id="supabase-branch-select"
data-testid="supabase-branch-select"
>
<SelectValue placeholder="Select a branch" />
</SelectTrigger>
<SelectContent>
{branches.map((branch) => (
<SelectItem
key={branch.projectRef}
value={branch.projectRef}
>
{branch.name}
{branch.isDefault && " (Default)"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button variant="destructive" onClick={handleUnsetProject}>
Disconnect Project
</Button>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="mt-1">
<CardHeader>
<CardTitle>Supabase Projects</CardTitle>
<CardDescription>
Select a Supabase project to connect to this app
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : error ? (
<div className="text-red-500">
Error loading projects: {error.message}
<Button
variant="outline"
className="mt-2"
onClick={() => loadProjects()}
>
Retry
</Button>
</div>
) : (
<div className="space-y-4">
{projects.length === 0 ? (
<p className="text-sm text-gray-500">
No projects found in your Supabase account.
</p>
) : (
<>
<div className="space-y-2">
<Label htmlFor="project-select">Project</Label>
<Select
value={currentProjectId || ""}
onValueChange={handleProjectSelect}
>
<SelectTrigger id="project-select">
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name || project.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{currentProjectId && (
<div className="text-sm text-gray-500">
This app is connected to project:{" "}
{projects.find((p) => p.id === currentProjectId)?.name ||
currentProjectId}
</div>
)}
</>
)}
</div>
)}
</CardContent>
</Card>
);
}
return (
<div className="flex flex-col space-y-4 p-4 border rounded-md">
<div className="flex flex-col md:flex-row items-center justify-between">
<h2 className="text-lg font-medium">Integrations</h2>
<img
onClick={async () => {
if (settings?.isTestMode) {
await IpcClient.getInstance().fakeHandleSupabaseConnect({
appId,
fakeProjectId: "fake-project-id",
});
} else {
await IpcClient.getInstance().openExternalUrl(
"https://supabase-oauth.dyad.sh/api/connect-supabase/login",
);
}
}}
src={isDarkMode ? connectSupabaseDark : connectSupabaseLight}
alt="Connect to Supabase"
className="w-full h-10 min-h-8 min-w-20 cursor-pointer"
data-testid="connect-supabase-button"
// className="h-10"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
// We might need a Supabase icon here, but for now, let's use a generic one or text.
// import { Supabase } from "lucide-react"; // Placeholder
import { DatabaseZap } from "lucide-react"; // Using DatabaseZap as a placeholder
import { useSettings } from "@/hooks/useSettings";
import { showSuccess, showError } from "@/lib/toast";
export function SupabaseIntegration() {
const { settings, updateSettings } = useSettings();
const [isDisconnecting, setIsDisconnecting] = useState(false);
const handleDisconnectFromSupabase = async () => {
setIsDisconnecting(true);
try {
// Clear the entire supabase object in settings
const result = await updateSettings({
supabase: undefined,
// Also disable the migration setting on disconnect
enableSupabaseWriteSqlMigration: false,
});
if (result) {
showSuccess("Successfully disconnected from Supabase");
} else {
showError("Failed to disconnect from Supabase");
}
} catch (err: any) {
showError(
err.message || "An error occurred while disconnecting from Supabase",
);
} finally {
setIsDisconnecting(false);
}
};
const handleMigrationSettingChange = async (enabled: boolean) => {
try {
await updateSettings({
enableSupabaseWriteSqlMigration: enabled,
});
showSuccess("Setting updated");
} catch (err: any) {
showError(err.message || "Failed to update setting");
}
};
// Check if there's any Supabase accessToken to determine connection status
const isConnected = !!settings?.supabase?.accessToken;
if (!isConnected) {
return null;
}
return (
<div>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Supabase Integration
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Your account is connected to Supabase.
</p>
</div>
<Button
onClick={handleDisconnectFromSupabase}
variant="destructive"
size="sm"
disabled={isDisconnecting}
className="flex items-center gap-2"
>
{isDisconnecting ? "Disconnecting..." : "Disconnect from Supabase"}
<DatabaseZap className="h-4 w-4" />
</Button>
</div>
<div className="mt-4">
<div className="flex items-center space-x-3">
<Switch
id="supabase-migrations"
checked={!!settings?.enableSupabaseWriteSqlMigration}
onCheckedChange={handleMigrationSettingChange}
/>
<div className="space-y-1">
<Label
htmlFor="supabase-migrations"
className="text-sm font-medium"
>
Write SQL migration files
</Label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Generate SQL migration files when modifying your Supabase schema.
This helps you track database changes in version control, though
these files aren't used for chat context, which uses the live
schema.
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { IpcClient } from "@/ipc/ipc_client";
import React from "react";
import { Button } from "./ui/button";
import { atom, useAtom } from "jotai";
import { useSettings } from "@/hooks/useSettings";
const hideBannerAtom = atom(false);
export function PrivacyBanner() {
const [hideBanner, setHideBanner] = useAtom(hideBannerAtom);
const { settings, updateSettings } = useSettings();
// TODO: Implement state management for banner visibility and user choice
// TODO: Implement functionality for Accept, Reject, Ask me later buttons
// TODO: Add state to hide/show banner based on user choice
if (hideBanner) {
return null;
}
if (settings?.telemetryConsent !== "unset") {
return null;
}
return (
<div className="fixed bg-(--background)/90 bottom-4 right-4 backdrop-blur-md border border-gray-200 dark:border-gray-700 p-4 rounded-lg shadow-lg z-50 max-w-md">
<div className="flex flex-col gap-3">
<div>
<h4 className="text-base font-semibold text-gray-800 dark:text-gray-200">
Share anonymous data?
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Help improve Dyad with anonymous usage data.
<em className="block italic mt-0.5">
Note: this does not log your code or messages.
</em>
<a
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://dyad.sh/docs/policies/privacy-policy",
);
}}
className="cursor-pointer text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
Learn more
</a>
</p>
</div>
<div className="flex gap-2 justify-end">
<Button
variant="default"
onClick={() => {
updateSettings({ telemetryConsent: "opted_in" });
}}
data-testid="telemetry-accept-button"
>
Accept
</Button>
<Button
variant="secondary"
onClick={() => {
updateSettings({ telemetryConsent: "opted_out" });
}}
data-testid="telemetry-reject-button"
>
Reject
</Button>
<Button
variant="ghost"
onClick={() => setHideBanner(true)}
data-testid="telemetry-later-button"
>
Later
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { useSettings } from "@/hooks/useSettings";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
export function TelemetrySwitch() {
const { settings, updateSettings } = useSettings();
return (
<div className="flex items-center space-x-2">
<Switch
id="telemetry-switch"
checked={settings?.telemetryConsent === "opted_in"}
onCheckedChange={() => {
updateSettings({
telemetryConsent:
settings?.telemetryConsent === "opted_in"
? "opted_out"
: "opted_in",
});
}}
/>
<Label htmlFor="telemetry-switch">Telemetry</Label>
</div>
);
}

View File

@@ -0,0 +1,163 @@
import React, { useState } from "react";
import { ArrowLeft } from "lucide-react";
import { IpcClient } from "@/ipc/ipc_client";
import { useSettings } from "@/hooks/useSettings";
import { CommunityCodeConsentDialog } from "./CommunityCodeConsentDialog";
import type { Template } from "@/shared/templates";
import { Button } from "./ui/button";
import { cn } from "@/lib/utils";
import { showWarning } from "@/lib/toast";
interface TemplateCardProps {
template: Template;
isSelected: boolean;
onSelect: (templateId: string) => void;
onCreateApp: () => void;
}
export const TemplateCard: React.FC<TemplateCardProps> = ({
template,
isSelected,
onSelect,
onCreateApp,
}) => {
const { settings, updateSettings } = useSettings();
const [showConsentDialog, setShowConsentDialog] = useState(false);
const handleCardClick = () => {
// If it's a community template and user hasn't accepted community code yet, show dialog
if (!template.isOfficial && !settings?.acceptedCommunityCode) {
setShowConsentDialog(true);
return;
}
if (template.requiresNeon && !settings?.neon?.accessToken) {
showWarning("Please connect your Neon account to use this template.");
return;
}
// Otherwise, proceed with selection
onSelect(template.id);
};
const handleConsentAccept = () => {
// Update settings to accept community code
updateSettings({ acceptedCommunityCode: true });
// Select the template
onSelect(template.id);
// Close dialog
setShowConsentDialog(false);
};
const handleConsentCancel = () => {
// Just close dialog, don't update settings or select template
setShowConsentDialog(false);
};
const handleGithubClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (template.githubUrl) {
IpcClient.getInstance().openExternalUrl(template.githubUrl);
}
};
return (
<>
<div
onClick={handleCardClick}
className={`
bg-white dark:bg-gray-800 rounded-xl shadow-sm overflow-hidden
transform transition-all duration-300 ease-in-out
cursor-pointer group relative
${
isSelected
? "ring-2 ring-blue-500 dark:ring-blue-400 shadow-xl"
: "hover:shadow-lg hover:-translate-y-1"
}
`}
>
<div className="relative">
<img
src={template.imageUrl}
alt={template.title}
className={`w-full h-52 object-cover transition-opacity duration-300 group-hover:opacity-80 ${
isSelected ? "opacity-75" : ""
}`}
/>
{isSelected && (
<span className="absolute top-3 right-3 bg-blue-600 text-white text-xs font-bold px-3 py-1.5 rounded-md shadow-lg">
Selected
</span>
)}
</div>
<div className="p-4">
<div className="flex justify-between items-center mb-1.5">
<h2
className={`text-lg font-semibold ${
isSelected
? "text-blue-600 dark:text-blue-400"
: "text-gray-900 dark:text-white"
}`}
>
{template.title}
</h2>
{template.isOfficial && !template.isExperimental && (
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
isSelected
? "bg-blue-100 text-blue-700 dark:bg-blue-600 dark:text-blue-100"
: "bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-200"
}`}
>
Official
</span>
)}
{template.isExperimental && (
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-700 dark:text-yellow-200">
Experimental
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3 h-10 overflow-y-auto">
{template.description}
</p>
{template.githubUrl && (
<a
className={`inline-flex items-center text-sm font-medium transition-colors duration-200 ${
isSelected
? "text-blue-500 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200"
: "text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
}`}
onClick={handleGithubClick}
>
View on GitHub{" "}
<ArrowLeft className="w-4 h-4 ml-1 transform rotate-180" />
</a>
)}
<Button
onClick={(e) => {
e.stopPropagation();
onCreateApp();
}}
size="sm"
className={cn(
"w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold mt-2",
settings?.selectedTemplateId !== template.id && "invisible",
)}
>
Create App
</Button>
</div>
</div>
<CommunityCodeConsentDialog
isOpen={showConsentDialog}
onAccept={handleConsentAccept}
onCancel={handleConsentCancel}
/>
</>
);
};

View File

@@ -0,0 +1,80 @@
import React from "react";
import { useSettings } from "@/hooks/useSettings";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface OptionInfo {
value: string;
label: string;
description: string;
}
const defaultValue = "medium";
const options: OptionInfo[] = [
{
value: "low",
label: "Low",
description:
"Minimal thinking tokens for faster responses and lower costs.",
},
{
value: defaultValue,
label: "Medium (default)",
description: "Balanced thinking for most conversations.",
},
{
value: "high",
label: "High",
description:
"Extended thinking for complex problems requiring deep analysis.",
},
];
export const ThinkingBudgetSelector: React.FC = () => {
const { settings, updateSettings } = useSettings();
const handleValueChange = (value: string) => {
updateSettings({ thinkingBudget: value as "low" | "medium" | "high" });
};
// Determine the current value
const currentValue = settings?.thinkingBudget || defaultValue;
// Find the current option to display its description
const currentOption =
options.find((opt) => opt.value === currentValue) || options[1];
return (
<div className="space-y-1">
<div className="flex items-center gap-4">
<label
htmlFor="thinking-budget"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Thinking Budget
</label>
<Select value={currentValue} onValueChange={handleValueChange}>
<SelectTrigger className="w-[180px]" id="thinking-budget">
<SelectValue placeholder="Select budget" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{currentOption.description}
</div>
</div>
);
};

View File

@@ -0,0 +1,659 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Globe } from "lucide-react";
import { IpcClient } from "@/ipc/ipc_client";
import { useSettings } from "@/hooks/useSettings";
import { useLoadApp } from "@/hooks/useLoadApp";
import { useVercelDeployments } from "@/hooks/useVercelDeployments";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { App } from "@/ipc/ipc_types";
interface VercelConnectorProps {
appId: number | null;
folderName: string;
}
interface VercelProject {
id: string;
name: string;
framework: string | null;
}
interface ConnectedVercelConnectorProps {
appId: number;
app: App;
refreshApp: () => void;
}
interface UnconnectedVercelConnectorProps {
appId: number | null;
folderName: string;
settings: any;
refreshSettings: () => void;
refreshApp: () => void;
}
function ConnectedVercelConnector({
appId,
app,
refreshApp,
}: ConnectedVercelConnectorProps) {
const {
deployments,
isLoading: isLoadingDeployments,
error: deploymentsError,
getDeployments: handleGetDeployments,
disconnectProject,
isDisconnecting,
disconnectError,
} = useVercelDeployments(appId);
const handleDisconnectProject = async () => {
await disconnectProject();
refreshApp();
};
return (
<div
className="mt-4 w-full rounded-md"
data-testid="vercel-connected-project"
>
<p className="text-sm text-gray-600 dark:text-gray-300">
Connected to Vercel Project:
</p>
<a
onClick={(e) => {
e.preventDefault();
IpcClient.getInstance().openExternalUrl(
`https://vercel.com/${app.vercelTeamSlug}/${app.vercelProjectName}`,
);
}}
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noopener noreferrer"
>
{app.vercelProjectName}
</a>
{app.vercelDeploymentUrl && (
<div className="mt-2">
<p className="text-sm text-gray-600 dark:text-gray-300">
Live URL:{" "}
<a
onClick={(e) => {
e.preventDefault();
if (app.vercelDeploymentUrl) {
IpcClient.getInstance().openExternalUrl(
app.vercelDeploymentUrl,
);
}
}}
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400 font-mono"
target="_blank"
rel="noopener noreferrer"
>
{app.vercelDeploymentUrl}
</a>
</p>
</div>
)}
<div className="mt-2 flex gap-2">
<Button onClick={handleGetDeployments} disabled={isLoadingDeployments}>
{isLoadingDeployments ? (
<>
<svg
className="animate-spin h-5 w-5 mr-2 inline"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
style={{ display: "inline" }}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Getting Deployments...
</>
) : (
"Refresh Deployments"
)}
</Button>
<Button
onClick={handleDisconnectProject}
disabled={isDisconnecting}
variant="outline"
>
{isDisconnecting ? "Disconnecting..." : "Disconnect from project"}
</Button>
</div>
{deploymentsError && (
<div className="mt-2">
<p className="text-red-600">{deploymentsError}</p>
</div>
)}
{deployments.length > 0 && (
<div className="mt-4">
<h4 className="font-medium mb-2">Recent Deployments:</h4>
<div className="space-y-2">
{deployments.map((deployment) => (
<div
key={deployment.uid}
className="bg-gray-50 dark:bg-gray-800 rounded-md p-3"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
deployment.readyState === "READY"
? "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300"
: deployment.readyState === "BUILDING"
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300"
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
}`}
>
{deployment.readyState}
</span>
<span className="text-sm text-gray-600 dark:text-gray-300">
{new Date(deployment.createdAt).toLocaleString()}
</span>
</div>
<a
onClick={(e) => {
e.preventDefault();
IpcClient.getInstance().openExternalUrl(
`https://${deployment.url}`,
);
}}
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400 text-sm"
target="_blank"
rel="noopener noreferrer"
>
<Globe className="h-4 w-4 inline mr-1" />
View
</a>
</div>
</div>
))}
</div>
</div>
)}
{disconnectError && (
<p className="text-red-600 mt-2">{disconnectError}</p>
)}
</div>
);
}
function UnconnectedVercelConnector({
appId,
folderName,
settings,
refreshSettings,
refreshApp,
}: UnconnectedVercelConnectorProps) {
// --- Manual Token Entry State ---
const [accessToken, setAccessToken] = useState("");
const [isSavingToken, setIsSavingToken] = useState(false);
const [tokenError, setTokenError] = useState<string | null>(null);
const [tokenSuccess, setTokenSuccess] = useState(false);
// --- Project Setup State ---
const [projectSetupMode, setProjectSetupMode] = useState<
"create" | "existing"
>("create");
const [availableProjects, setAvailableProjects] = useState<VercelProject[]>(
[],
);
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [selectedProject, setSelectedProject] = useState<string>("");
// Create new project state
const [projectName, setProjectName] = useState(folderName);
const [projectAvailable, setProjectAvailable] = useState<boolean | null>(
null,
);
const [projectCheckError, setProjectCheckError] = useState<string | null>(
null,
);
const [isCheckingProject, setIsCheckingProject] = useState(false);
const [isCreatingProject, setIsCreatingProject] = useState(false);
const [createProjectError, setCreateProjectError] = useState<string | null>(
null,
);
const [createProjectSuccess, setCreateProjectSuccess] =
useState<boolean>(false);
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Load available projects when Vercel is connected
useEffect(() => {
if (settings?.vercelAccessToken && projectSetupMode === "existing") {
loadAvailableProjects();
}
}, [settings?.vercelAccessToken, projectSetupMode]);
// Cleanup debounce timer on unmount
useEffect(() => {
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, []);
const loadAvailableProjects = async () => {
setIsLoadingProjects(true);
try {
const projects = await IpcClient.getInstance().listVercelProjects();
setAvailableProjects(projects);
} catch (error) {
console.error("Failed to load Vercel projects:", error);
} finally {
setIsLoadingProjects(false);
}
};
const handleSaveAccessToken = async (e: React.FormEvent) => {
e.preventDefault();
if (!accessToken.trim()) return;
setIsSavingToken(true);
setTokenError(null);
setTokenSuccess(false);
try {
await IpcClient.getInstance().saveVercelAccessToken({
token: accessToken.trim(),
});
setTokenSuccess(true);
setAccessToken("");
refreshSettings();
} catch (err: any) {
setTokenError(err.message || "Failed to save access token.");
} finally {
setIsSavingToken(false);
}
};
const checkProjectAvailability = useCallback(async (name: string) => {
setProjectCheckError(null);
setProjectAvailable(null);
if (!name) return;
setIsCheckingProject(true);
try {
const result = await IpcClient.getInstance().isVercelProjectAvailable({
name,
});
setProjectAvailable(result.available);
if (!result.available) {
setProjectCheckError(result.error || "Project name is not available.");
}
} catch (err: any) {
setProjectCheckError(
err.message || "Failed to check project availability.",
);
} finally {
setIsCheckingProject(false);
}
}, []);
const debouncedCheckProjectAvailability = useCallback(
(name: string) => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
debounceTimeoutRef.current = setTimeout(() => {
checkProjectAvailability(name);
}, 500);
},
[checkProjectAvailability],
);
const handleSetupProject = async (e: React.FormEvent) => {
e.preventDefault();
if (!appId) return;
setCreateProjectError(null);
setIsCreatingProject(true);
setCreateProjectSuccess(false);
try {
if (projectSetupMode === "create") {
await IpcClient.getInstance().createVercelProject({
name: projectName,
appId,
});
} else {
await IpcClient.getInstance().connectToExistingVercelProject({
projectId: selectedProject,
appId,
});
}
setCreateProjectSuccess(true);
setProjectCheckError(null);
refreshApp();
} catch (err: any) {
setCreateProjectError(
err.message ||
`Failed to ${projectSetupMode === "create" ? "create" : "connect to"} project.`,
);
} finally {
setIsCreatingProject(false);
}
};
if (!settings?.vercelAccessToken) {
return (
<div className="mt-1 w-full" data-testid="vercel-unconnected-project">
<div className="w-ful">
<div className="flex items-center gap-2 mb-4">
<h3 className="font-medium">Connect to Vercel</h3>
</div>
<div className="space-y-4">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-3">
<p className="text-sm text-blue-800 dark:text-blue-200 mb-2">
To connect your app to Vercel, you'll need to create an access
token:
</p>
<ol className="list-decimal list-inside text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li>If you don't have a Vercel account, sign up first</li>
<li>Go to Vercel settings to create a token</li>
<li>Copy the token and paste it below</li>
</ol>
<div className="flex gap-2 mt-3">
<Button
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://vercel.com/signup",
);
}}
variant="outline"
className="flex-1"
>
Sign Up for Vercel
</Button>
<Button
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://vercel.com/account/settings/tokens",
);
}}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
>
Open Vercel Settings
</Button>
</div>
</div>
<form onSubmit={handleSaveAccessToken} className="space-y-3">
<div>
<Label className="block text-sm font-medium mb-1">
Vercel Access Token
</Label>
<Input
type="password"
placeholder="Enter your Vercel access token"
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
disabled={isSavingToken}
className="w-full"
/>
</div>
<Button
type="submit"
disabled={!accessToken.trim() || isSavingToken}
className="w-full"
>
{isSavingToken ? (
<>
<svg
className="animate-spin h-4 w-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Saving Token...
</>
) : (
"Save Access Token"
)}
</Button>
</form>
{tokenError && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
<p className="text-sm text-red-800 dark:text-red-200">
{tokenError}
</p>
</div>
)}
{tokenSuccess && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md p-3">
<p className="text-sm text-green-800 dark:text-green-200">
Successfully connected to Vercel! You can now set up your
project below.
</p>
</div>
)}
</div>
</div>
</div>
);
}
return (
<div className="mt-4 w-full rounded-md" data-testid="vercel-setup-project">
{/* Collapsible Header */}
<div className="font-medium mb-2">Set up your Vercel project</div>
{/* Collapsible Content */}
<div
className={`overflow-hidden transition-all duration-300 ease-in-out`}
>
<div className="pt-0 space-y-4">
{/* Mode Selection */}
<div>
<div className="flex rounded-md border border-gray-200 dark:border-gray-700">
<Button
type="button"
variant={projectSetupMode === "create" ? "default" : "ghost"}
className={`flex-1 rounded-none rounded-l-md border-0 ${
projectSetupMode === "create"
? "bg-primary text-primary-foreground"
: "hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
onClick={() => {
setProjectSetupMode("create");
setCreateProjectError(null);
setCreateProjectSuccess(false);
}}
>
Create new project
</Button>
<Button
type="button"
variant={projectSetupMode === "existing" ? "default" : "ghost"}
className={`flex-1 rounded-none rounded-r-md border-0 border-l border-gray-200 dark:border-gray-700 ${
projectSetupMode === "existing"
? "bg-primary text-primary-foreground"
: "hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
onClick={() => {
setProjectSetupMode("existing");
setCreateProjectError(null);
setCreateProjectSuccess(false);
}}
>
Connect to existing project
</Button>
</div>
</div>
<form className="space-y-4" onSubmit={handleSetupProject}>
{projectSetupMode === "create" ? (
<>
<div>
<Label className="block text-sm font-medium">
Project Name
</Label>
<Input
data-testid="vercel-create-project-name-input"
className="w-full mt-1"
value={projectName}
onChange={(e) => {
const newValue = e.target.value;
setProjectName(newValue);
setProjectAvailable(null);
setProjectCheckError(null);
debouncedCheckProjectAvailability(newValue);
}}
disabled={isCreatingProject}
/>
{isCheckingProject && (
<p className="text-xs text-gray-500 mt-1">
Checking availability...
</p>
)}
{projectAvailable === true && (
<p className="text-xs text-green-600 mt-1">
Project name is available!
</p>
)}
{projectAvailable === false && (
<p className="text-xs text-red-600 mt-1">
{projectCheckError}
</p>
)}
</div>
</>
) : (
<>
<div>
<Label className="block text-sm font-medium">
Select Project
</Label>
<Select
value={selectedProject}
onValueChange={setSelectedProject}
disabled={isLoadingProjects}
>
<SelectTrigger
className="w-full mt-1"
data-testid="vercel-project-select"
>
<SelectValue
placeholder={
isLoadingProjects
? "Loading projects..."
: "Select a project"
}
/>
</SelectTrigger>
<SelectContent>
{availableProjects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}{" "}
{project.framework && `(${project.framework})`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
<Button
type="submit"
disabled={
isCreatingProject ||
(projectSetupMode === "create" &&
(projectAvailable === false || !projectName)) ||
(projectSetupMode === "existing" && !selectedProject)
}
>
{isCreatingProject
? projectSetupMode === "create"
? "Creating..."
: "Connecting..."
: projectSetupMode === "create"
? "Create Project"
: "Connect to Project"}
</Button>
</form>
{createProjectError && (
<p className="text-red-600 mt-2">{createProjectError}</p>
)}
{createProjectSuccess && (
<p className="text-green-600 mt-2">
{projectSetupMode === "create"
? "Project created and linked!"
: "Connected to project!"}
</p>
)}
</div>
</div>
</div>
);
}
export function VercelConnector({ appId, folderName }: VercelConnectorProps) {
const { app, refreshApp } = useLoadApp(appId);
const { settings, refreshSettings } = useSettings();
if (app?.vercelProjectId && appId) {
return (
<ConnectedVercelConnector
appId={appId}
app={app}
refreshApp={refreshApp}
/>
);
} else {
return (
<UnconnectedVercelConnector
appId={appId}
folderName={folderName}
settings={settings}
refreshSettings={refreshSettings}
refreshApp={refreshApp}
/>
);
}
}

View File

@@ -0,0 +1,61 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useSettings } from "@/hooks/useSettings";
import { showSuccess, showError } from "@/lib/toast";
export function VercelIntegration() {
const { settings, updateSettings } = useSettings();
const [isDisconnecting, setIsDisconnecting] = useState(false);
const handleDisconnectFromVercel = async () => {
setIsDisconnecting(true);
try {
const result = await updateSettings({
vercelAccessToken: undefined,
});
if (result) {
showSuccess("Successfully disconnected from Vercel");
} else {
showError("Failed to disconnect from Vercel");
}
} catch (err: any) {
showError(
err.message || "An error occurred while disconnecting from Vercel",
);
} finally {
setIsDisconnecting(false);
}
};
const isConnected = !!settings?.vercelAccessToken;
if (!isConnected) {
return null;
}
return (
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Vercel Integration
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Your account is connected to Vercel.
</p>
</div>
<Button
onClick={handleDisconnectFromVercel}
variant="destructive"
size="sm"
disabled={isDisconnecting}
className="flex items-center gap-2"
>
{isDisconnecting ? "Disconnecting..." : "Disconnect from Vercel"}
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 22.525H0l12-21.05 12 21.05z" />
</svg>
</Button>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { useMemo } from "react";
import { useSettings } from "@/hooks/useSettings";
import { ZoomLevel, ZoomLevelSchema } from "@/lib/schemas";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const ZOOM_LEVEL_LABELS: Record<ZoomLevel, string> = {
"90": "90%",
"100": "100%",
"110": "110%",
"125": "125%",
"150": "150%",
};
const ZOOM_LEVEL_DESCRIPTIONS: Record<ZoomLevel, string> = {
"90": "Slightly zoomed out to fit more content on screen.",
"100": "Default zoom level.",
"110": "Zoom in a little for easier reading.",
"125": "Large zoom for improved readability.",
"150": "Maximum zoom for maximum accessibility.",
};
const DEFAULT_ZOOM_LEVEL: ZoomLevel = "100";
export function ZoomSelector() {
const { settings, updateSettings } = useSettings();
const currentZoomLevel: ZoomLevel = useMemo(() => {
const value = settings?.zoomLevel ?? DEFAULT_ZOOM_LEVEL;
return ZoomLevelSchema.safeParse(value).success
? (value as ZoomLevel)
: DEFAULT_ZOOM_LEVEL;
}, [settings?.zoomLevel]);
return (
<div className="space-y-2">
<div className="flex flex-col gap-1">
<Label htmlFor="zoom-level">Zoom level</Label>
<p className="text-sm text-muted-foreground">
Adjusts the zoom level to make content easier to read.
</p>
</div>
<Select
value={currentZoomLevel}
onValueChange={(value) =>
updateSettings({ zoomLevel: value as ZoomLevel })
}
>
<SelectTrigger id="zoom-level" className="w-[220px]">
<SelectValue placeholder="Select zoom level" />
</SelectTrigger>
<SelectContent>
{Object.entries(ZOOM_LEVEL_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
<div className="flex flex-col text-left">
<span>{label}</span>
<span className="text-xs text-muted-foreground">
{ZOOM_LEVEL_DESCRIPTIONS[value as ZoomLevel]}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,231 @@
import {
Home,
Inbox,
Settings,
HelpCircle,
Store,
BookOpen,
} from "lucide-react";
import { Link, useRouterState } from "@tanstack/react-router";
import { useSidebar } from "@/components/ui/sidebar"; // import useSidebar hook
import { useEffect, useState, useRef } from "react";
import { useAtom } from "jotai";
import { dropdownOpenAtom } from "@/atoms/uiAtoms";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { ChatList } from "./ChatList";
import { AppList } from "./AppList";
import { HelpDialog } from "./HelpDialog"; // Import the new dialog
import { SettingsList } from "./SettingsList";
// Menu items.
const items = [
{
title: "Apps",
to: "/",
icon: Home,
},
{
title: "Chat",
to: "/chat",
icon: Inbox,
},
{
title: "Settings",
to: "/settings",
icon: Settings,
},
{
title: "Library",
to: "/library",
icon: BookOpen,
},
{
title: "Hub",
to: "/hub",
icon: Store,
},
];
// Hover state types
type HoverState =
| "start-hover:app"
| "start-hover:chat"
| "start-hover:settings"
| "start-hover:library"
| "clear-hover"
| "no-hover";
export function AppSidebar() {
const { state, toggleSidebar } = useSidebar(); // retrieve current sidebar state
const [hoverState, setHoverState] = useState<HoverState>("no-hover");
const expandedByHover = useRef(false);
const [isHelpDialogOpen, setIsHelpDialogOpen] = useState(false); // State for dialog
const [isDropdownOpen] = useAtom(dropdownOpenAtom);
useEffect(() => {
if (hoverState.startsWith("start-hover") && state === "collapsed") {
expandedByHover.current = true;
toggleSidebar();
}
if (
hoverState === "clear-hover" &&
state === "expanded" &&
expandedByHover.current &&
!isDropdownOpen
) {
toggleSidebar();
expandedByHover.current = false;
setHoverState("no-hover");
}
}, [hoverState, toggleSidebar, state, setHoverState, isDropdownOpen]);
const routerState = useRouterState();
const isAppRoute =
routerState.location.pathname === "/" ||
routerState.location.pathname.startsWith("/app-details");
const isChatRoute = routerState.location.pathname === "/chat";
const isSettingsRoute = routerState.location.pathname.startsWith("/settings");
let selectedItem: string | null = null;
if (hoverState === "start-hover:app") {
selectedItem = "Apps";
} else if (hoverState === "start-hover:chat") {
selectedItem = "Chat";
} else if (hoverState === "start-hover:settings") {
selectedItem = "Settings";
} else if (hoverState === "start-hover:library") {
selectedItem = "Library";
} else if (state === "expanded") {
if (isAppRoute) {
selectedItem = "Apps";
} else if (isChatRoute) {
selectedItem = "Chat";
} else if (isSettingsRoute) {
selectedItem = "Settings";
}
}
return (
<Sidebar
collapsible="icon"
onMouseLeave={() => {
if (!isDropdownOpen) {
setHoverState("clear-hover");
}
}}
>
<SidebarContent className="overflow-hidden">
<div className="flex mt-8">
{/* Left Column: Menu items */}
<div className="">
<SidebarTrigger
onMouseEnter={() => {
setHoverState("clear-hover");
}}
/>
<AppIcons onHoverChange={setHoverState} />
</div>
{/* Right Column: Chat List Section */}
<div className="w-[240px]">
<AppList show={selectedItem === "Apps"} />
<ChatList show={selectedItem === "Chat"} />
<SettingsList show={selectedItem === "Settings"} />
</div>
</div>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
{/* Change button to open dialog instead of linking */}
<SidebarMenuButton
size="sm"
className="font-medium w-14 flex flex-col items-center gap-1 h-14 mb-2 rounded-2xl"
onClick={() => setIsHelpDialogOpen(true)} // Open dialog on click
>
<HelpCircle className="h-5 w-5" />
<span className={"text-xs"}>Help</span>
</SidebarMenuButton>
<HelpDialog
isOpen={isHelpDialogOpen}
onClose={() => setIsHelpDialogOpen(false)}
/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}
function AppIcons({
onHoverChange,
}: {
onHoverChange: (state: HoverState) => void;
}) {
const routerState = useRouterState();
const pathname = routerState.location.pathname;
return (
// When collapsed: only show the main menu
<SidebarGroup className="pr-0">
{/* <SidebarGroupLabel>Dyad</SidebarGroupLabel> */}
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
const isActive =
(item.to === "/" && pathname === "/") ||
(item.to !== "/" && pathname.startsWith(item.to));
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
size="sm"
className="font-medium w-14"
>
<Link
to={item.to}
className={`flex flex-col items-center gap-1 h-14 mb-2 rounded-2xl ${
isActive ? "bg-sidebar-accent" : ""
}`}
onMouseEnter={() => {
if (item.title === "Apps") {
onHoverChange("start-hover:app");
} else if (item.title === "Chat") {
onHoverChange("start-hover:chat");
} else if (item.title === "Settings") {
onHoverChange("start-hover:settings");
} else if (item.title === "Library") {
onHoverChange("start-hover:library");
}
}}
>
<div className="flex flex-col items-center gap-1">
<item.icon className="h-5 w-5" />
<span className={"text-xs"}>{item.title}</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@@ -0,0 +1,82 @@
import { formatDistanceToNow } from "date-fns";
import { Star } from "lucide-react";
import { SidebarMenuItem } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { App } from "@/ipc/ipc_types";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
type AppItemProps = {
app: App;
handleAppClick: (id: number) => void;
selectedAppId: number | null;
handleToggleFavorite: (appId: number, e: React.MouseEvent) => void;
isFavoriteLoading: boolean;
};
export function AppItem({
app,
handleAppClick,
selectedAppId,
handleToggleFavorite,
isFavoriteLoading,
}: AppItemProps) {
return (
<SidebarMenuItem className="mb-1 relative ">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex w-[190px] items-center">
<Button
variant="ghost"
onClick={() => handleAppClick(app.id)}
className={`justify-start w-full text-left py-3 hover:bg-sidebar-accent/80 ${
selectedAppId === app.id
? "bg-sidebar-accent text-sidebar-accent-foreground"
: ""
}`}
data-testid={`app-list-item-${app.name}`}
>
<div className="flex flex-col w-4/5">
<span className="truncate">{app.name}</span>
<span className="text-xs text-gray-500">
{formatDistanceToNow(new Date(app.createdAt), {
addSuffix: true,
})}
</span>
</div>
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => handleToggleFavorite(app.id, e)}
disabled={isFavoriteLoading}
className="absolute top-1 right-1 p-1 mx-1 h-6 w-6 z-10"
key={app.id}
data-testid="favorite-button"
>
<Star
size={12}
className={
app.isFavorite
? "fill-[#6c55dc] text-[#6c55dc]"
: selectedAppId === app.id
? "hover:fill-black hover:text-black"
: "hover:fill-[#6c55dc] hover:stroke-[#6c55dc] hover:text-[#6c55dc]"
}
/>
</Button>
</div>
</TooltipTrigger>
<TooltipContent side="right">
<p>{app.name}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SidebarMenuItem>
);
}

View File

@@ -0,0 +1,72 @@
import { FileText, X, MessageSquare, Upload } from "lucide-react";
import type { FileAttachment } from "@/ipc/ipc_types";
interface AttachmentsListProps {
attachments: FileAttachment[];
onRemove: (index: number) => void;
}
export function AttachmentsList({
attachments,
onRemove,
}: AttachmentsListProps) {
if (attachments.length === 0) return null;
return (
<div className="px-2 pt-2 flex flex-wrap gap-1">
{attachments.map((attachment, index) => (
<div
key={index}
className="flex items-center bg-muted rounded-md px-2 py-1 text-xs gap-1"
title={`${attachment.file.name} (${(attachment.file.size / 1024).toFixed(1)}KB)`}
>
<div className="flex items-center gap-1">
{attachment.type === "upload-to-codebase" ? (
<Upload size={12} className="text-blue-600" />
) : (
<MessageSquare size={12} className="text-green-600" />
)}
{attachment.file.type.startsWith("image/") ? (
<div className="relative group">
<img
src={URL.createObjectURL(attachment.file)}
alt={attachment.file.name}
className="w-5 h-5 object-cover rounded"
onLoad={(e) =>
URL.revokeObjectURL((e.target as HTMLImageElement).src)
}
onError={(e) =>
URL.revokeObjectURL((e.target as HTMLImageElement).src)
}
/>
<div className="absolute hidden group-hover:block top-6 left-0 z-10">
<img
src={URL.createObjectURL(attachment.file)}
alt={attachment.file.name}
className="max-w-[200px] max-h-[200px] object-contain bg-white p-1 rounded shadow-lg"
onLoad={(e) =>
URL.revokeObjectURL((e.target as HTMLImageElement).src)
}
onError={(e) =>
URL.revokeObjectURL((e.target as HTMLImageElement).src)
}
/>
</div>
</div>
) : (
<FileText size={12} />
)}
</div>
<span className="truncate max-w-[120px]">{attachment.file.name}</span>
<button
onClick={() => onRemove(index)}
className="hover:bg-muted-foreground/20 rounded-full p-0.5"
aria-label="Remove attachment"
>
<X size={12} />
</button>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { useEffect, useMemo, useState } from "react";
import { Bell, Loader2, CheckCircle2 } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { getAllChats } from "@/lib/chat";
import type { ChatSummary } from "@/lib/schemas";
import { useAtomValue } from "jotai";
import {
isStreamingByIdAtom,
recentStreamChatIdsAtom,
} from "@/atoms/chatAtoms";
import { useLoadApps } from "@/hooks/useLoadApps";
import { useSelectChat } from "@/hooks/useSelectChat";
export function ChatActivityButton() {
const [open, setOpen] = useState(false);
const isStreamingById = useAtomValue(isStreamingByIdAtom);
const isAnyStreaming = useMemo(() => {
for (const v of isStreamingById.values()) {
if (v) return true;
}
return false;
}, [isStreamingById]);
return (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
className="no-app-region-drag relative flex items-center justify-center p-1.5 rounded-md text-sm hover:bg-[var(--background-darkest)] transition-colors"
data-testid="chat-activity-button"
>
{isAnyStreaming && (
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="block size-7 rounded-full border-3 border-blue-500/60 border-t-transparent animate-spin" />
</span>
)}
<Bell size={16} />
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Recent chat activity</TooltipContent>
</Tooltip>
<PopoverContent
align="end"
className="w-80 p-0 max-h-[50vh] overflow-y-auto"
>
<ChatActivityList onSelect={() => setOpen(false)} />
</PopoverContent>
</Popover>
);
}
function ChatActivityList({ onSelect }: { onSelect?: () => void }) {
const [chats, setChats] = useState<ChatSummary[]>([]);
const [loading, setLoading] = useState(true);
const isStreamingById = useAtomValue(isStreamingByIdAtom);
const recentStreamChatIds = useAtomValue(recentStreamChatIdsAtom);
const apps = useLoadApps();
const { selectChat } = useSelectChat();
useEffect(() => {
let mounted = true;
(async () => {
try {
const all = await getAllChats();
if (!mounted) return;
const recent = Array.from(recentStreamChatIds)
.map((id) => all.find((c) => c.id === id))
.filter((c) => c !== undefined);
// Sort recent first
setChats([...recent].reverse());
} finally {
if (mounted) setLoading(false);
}
})();
return () => {
mounted = false;
};
}, [recentStreamChatIds]);
const rows = useMemo(() => chats.slice(0, 30), [chats]);
if (loading) {
return (
<div className="p-4 text-sm text-muted-foreground flex items-center gap-2">
<Loader2 size={16} className="animate-spin" />
Loading activity
</div>
);
}
if (rows.length === 0) {
return (
<div className="p-4 text-sm text-muted-foreground">No recent chats</div>
);
}
return (
<div className="py-1" data-testid="chat-activity-list">
{rows.map((c) => {
const inProgress = isStreamingById.get(c.id) === true;
return (
<button
key={c.id}
className="w-full text-left px-3 py-2 flex items-center justify-between gap-2 rounded-md hover:bg-[var(--background-darker)] dark:hover:bg-[var(--background-lighter)] transition-colors"
onClick={() => {
onSelect?.();
selectChat({ chatId: c.id, appId: c.appId });
}}
data-testid={`chat-activity-list-item-${c.id}`}
>
<div className="min-w-0">
<div className="truncate text-sm font-medium">
{c.title ?? `Chat #${c.id}`}
</div>
<div className="text-xs text-muted-foreground">
{apps.apps.find((a) => a.id === c.appId)?.name}
</div>
</div>
<div className="flex items-center gap-2">
{inProgress ? (
<div className="flex items-center text-purple-600">
<Loader2 size={16} className="animate-spin" />
</div>
) : (
<div className="flex items-center text-emerald-600">
<CheckCircle2 size={16} />
</div>
)}
</div>
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { XCircle, AlertTriangle } from "lucide-react"; // Assuming lucide-react is used
interface ChatErrorProps {
error: string | null;
onDismiss: () => void;
}
export function ChatError({ error, onDismiss }: ChatErrorProps) {
if (!error) {
return null;
}
return (
<div className="relative flex items-start text-red-600 bg-red-100 border border-red-500 rounded-md text-sm p-3 mx-4 mb-2 shadow-sm">
<AlertTriangle
className="h-5 w-5 mr-2 flex-shrink-0"
aria-hidden="true"
/>
<span className="flex-1">{error}</span>
<button
onClick={onDismiss}
className="absolute top-1 right-1 p-1 rounded-full hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-red-400"
aria-label="Dismiss error"
>
<XCircle className="h-4 w-4 text-red-500 hover:text-red-700" />
</button>
</div>
);
}

View File

@@ -0,0 +1,237 @@
import { IpcClient } from "@/ipc/ipc_client";
import { AI_STREAMING_ERROR_MESSAGE_PREFIX } from "@/shared/texts";
import {
X,
ExternalLink as ExternalLinkIcon,
CircleArrowUp,
} from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
export function ChatErrorBox({
onDismiss,
error,
isDyadProEnabled,
}: {
onDismiss: () => void;
error: string;
isDyadProEnabled: boolean;
}) {
if (error.includes("doesn't have a free quota tier")) {
return (
<ChatErrorContainer onDismiss={onDismiss}>
{error}
<span className="ml-1">
<ExternalLink
href="https://dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=free-quota-error"
variant="primary"
>
Access with Dyad Pro
</ExternalLink>
</span>{" "}
or switch to another model.
</ChatErrorContainer>
);
}
// Important, this needs to come after the "free quota tier" check
// because it also includes this URL in the error message
//
// Sometimes Dyad Pro can return rate limit errors and we do not want to
// show the upgrade to Dyad Pro link in that case because they are
// already on the Dyad Pro plan.
if (
!isDyadProEnabled &&
(error.includes("Resource has been exhausted") ||
error.includes("https://ai.google.dev/gemini-api/docs/rate-limits") ||
error.includes("Provider returned error"))
) {
return (
<ChatErrorContainer onDismiss={onDismiss}>
{error}
<div className="mt-2 space-y-2 space-x-2">
<ExternalLink
href="https://dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=rate-limit-error"
variant="primary"
>
Upgrade to Dyad Pro
</ExternalLink>
<ExternalLink href="https://dyad.sh/docs/help/ai-rate-limit">
Troubleshooting guide
</ExternalLink>
</div>
</ChatErrorContainer>
);
}
if (error.includes("LiteLLM Virtual Key expected")) {
return (
<ChatInfoContainer onDismiss={onDismiss}>
<span>
Looks like you don't have a valid Dyad Pro key.{" "}
<ExternalLink
href="https://dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=invalid-pro-key-error"
variant="primary"
>
Upgrade to Dyad Pro
</ExternalLink>{" "}
today.
</span>
</ChatInfoContainer>
);
}
if (isDyadProEnabled && error.includes("ExceededBudget:")) {
return (
<ChatInfoContainer onDismiss={onDismiss}>
<span>
You have used all of your Dyad AI credits this month.{" "}
<ExternalLink
href="https://academy.dyad.sh/subscription?utm_source=dyad-app&utm_medium=app&utm_campaign=exceeded-budget-error"
variant="primary"
>
Reload or upgrade your subscription
</ExternalLink>{" "}
and get more AI credits
</span>
</ChatInfoContainer>
);
}
// This is a very long list of model fallbacks that clutters the error message.
//
// We are matching "Fallbacks=[{" and not just "Fallbacks=" because the fallback
// model itself can error and we want to include the fallback model error in the error message.
// Example: https://github.com/dyad-sh/dyad/issues/1849#issuecomment-3590685911
const fallbackPrefix = "Fallbacks=[{";
if (error.includes(fallbackPrefix)) {
error = error.split(fallbackPrefix)[0];
}
return (
<ChatErrorContainer onDismiss={onDismiss}>
{error}
<div className="mt-2 space-y-2 space-x-2">
{!isDyadProEnabled &&
error.includes(AI_STREAMING_ERROR_MESSAGE_PREFIX) &&
!error.includes("TypeError: terminated") && (
<ExternalLink
href="https://dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=general-error"
variant="primary"
>
Upgrade to Dyad Pro
</ExternalLink>
)}
<ExternalLink href="https://www.dyad.sh/docs/faq">
Read docs
</ExternalLink>
</div>
</ChatErrorContainer>
);
}
function ExternalLink({
href,
children,
variant = "secondary",
icon,
}: {
href: string;
children: React.ReactNode;
variant?: "primary" | "secondary";
icon?: React.ReactNode;
}) {
const baseClasses =
"cursor-pointer inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium shadow-sm focus:outline-none focus:ring-2";
const primaryClasses =
"bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500";
const secondaryClasses =
"bg-blue-50 text-blue-700 border border-blue-200 hover:bg-blue-100 hover:border-blue-300 focus:ring-blue-200";
const iconElement =
icon ??
(variant === "primary" ? (
<CircleArrowUp size={18} />
) : (
<ExternalLinkIcon size={14} />
));
return (
<a
className={`${baseClasses} ${
variant === "primary" ? primaryClasses : secondaryClasses
}`}
onClick={() => IpcClient.getInstance().openExternalUrl(href)}
>
<span>{children}</span>
{iconElement}
</a>
);
}
function ChatErrorContainer({
onDismiss,
children,
}: {
onDismiss: () => void;
children: React.ReactNode | string;
}) {
return (
<div className="relative mt-2 bg-red-50 border border-red-200 rounded-md shadow-sm p-2 mx-4">
<button
onClick={onDismiss}
className="absolute top-2.5 left-2 p-1 hover:bg-red-100 rounded"
>
<X size={14} className="text-red-500" />
</button>
<div className="pl-8 py-1 text-sm">
<div className="text-red-700 text-wrap">
{typeof children === "string" ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ children: linkChildren, ...props }) => (
<a
{...props}
onClick={(e) => {
e.preventDefault();
if (props.href) {
IpcClient.getInstance().openExternalUrl(props.href);
}
}}
className="text-blue-500 hover:text-blue-700"
>
{linkChildren}
</a>
),
}}
>
{children}
</ReactMarkdown>
) : (
children
)}
</div>
</div>
</div>
);
}
function ChatInfoContainer({
onDismiss,
children,
}: {
onDismiss: () => void;
children: React.ReactNode;
}) {
return (
<div className="relative mt-2 bg-sky-50 border border-sky-200 rounded-md shadow-sm p-2 mx-4">
<button
onClick={onDismiss}
className="absolute top-2.5 left-2 p-1 hover:bg-sky-100 rounded"
>
<X size={14} className="text-sky-600" />
</button>
<div className="pl-8 py-1 text-sm">
<div className="text-sky-800 text-wrap">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,218 @@
import {
PanelRightOpen,
History,
PlusCircle,
GitBranch,
Info,
} from "lucide-react";
import { PanelRightClose } from "lucide-react";
import { useAtom, useAtomValue } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useVersions } from "@/hooks/useVersions";
import { Button } from "../ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
import { IpcClient } from "@/ipc/ipc_client";
import { useRouter } from "@tanstack/react-router";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useChats } from "@/hooks/useChats";
import { showError, showSuccess } from "@/lib/toast";
import { useEffect } from "react";
import { useStreamChat } from "@/hooks/useStreamChat";
import { useCurrentBranch } from "@/hooks/useCurrentBranch";
import { useCheckoutVersion } from "@/hooks/useCheckoutVersion";
import { useRenameBranch } from "@/hooks/useRenameBranch";
import { isAnyCheckoutVersionInProgressAtom } from "@/store/appAtoms";
import { LoadingBar } from "../ui/LoadingBar";
interface ChatHeaderProps {
isVersionPaneOpen: boolean;
isPreviewOpen: boolean;
onTogglePreview: () => void;
onVersionClick: () => void;
}
export function ChatHeader({
isVersionPaneOpen,
isPreviewOpen,
onTogglePreview,
onVersionClick,
}: ChatHeaderProps) {
const appId = useAtomValue(selectedAppIdAtom);
const { versions, loading: versionsLoading } = useVersions(appId);
const { navigate } = useRouter();
const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom);
const { refreshChats } = useChats(appId);
const { isStreaming } = useStreamChat();
const isAnyCheckoutVersionInProgress = useAtomValue(
isAnyCheckoutVersionInProgressAtom,
);
const {
branchInfo,
isLoading: branchInfoLoading,
refetchBranchInfo,
} = useCurrentBranch(appId);
const { checkoutVersion, isCheckingOutVersion } = useCheckoutVersion();
const { renameBranch, isRenamingBranch } = useRenameBranch();
useEffect(() => {
if (appId) {
refetchBranchInfo();
}
}, [appId, selectedChatId, isStreaming, refetchBranchInfo]);
const handleCheckoutMainBranch = async () => {
if (!appId) return;
await checkoutVersion({ appId, versionId: "main" });
};
const handleRenameMasterToMain = async () => {
if (!appId) return;
// If this throws, it will automatically show an error toast
await renameBranch({ oldBranchName: "master", newBranchName: "main" });
showSuccess("Master branch renamed to main");
};
const handleNewChat = async () => {
if (appId) {
try {
const chatId = await IpcClient.getInstance().createChat(appId);
setSelectedChatId(chatId);
navigate({
to: "/chat",
search: { id: chatId },
});
await refreshChats();
} catch (error) {
showError(`Failed to create new chat: ${(error as any).toString()}`);
}
} else {
navigate({ to: "/" });
}
};
// REMINDER: KEEP UP TO DATE WITH app_handlers.ts
const versionPostfix = versions.length === 100_000 ? `+` : "";
const isNotMainBranch = branchInfo && branchInfo.branch !== "main";
const currentBranchName = branchInfo?.branch;
return (
<div className="flex flex-col w-full @container">
<LoadingBar isVisible={isAnyCheckoutVersionInProgress} />
{/* If the version pane is open, it's expected to not always be on the main branch. */}
{isNotMainBranch && !isVersionPaneOpen && (
<div className="flex flex-col @sm:flex-row items-center justify-between px-4 py-2 bg-amber-100 dark:bg-amber-900 text-amber-800 dark:text-amber-200">
<div className="flex items-center gap-2 text-sm">
<GitBranch size={16} />
<span>
{currentBranchName === "<no-branch>" && (
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1">
{isAnyCheckoutVersionInProgress ? (
<>
<span>
Please wait, switching back to latest version...
</span>
</>
) : (
<>
<strong>Warning:</strong>
<span>You are not on a branch</span>
<Info size={14} />
</>
)}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{isAnyCheckoutVersionInProgress
? "Version checkout is currently in progress"
: "Checkout main branch, otherwise changes will not be saved properly"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
{currentBranchName && currentBranchName !== "<no-branch>" && (
<span>
You are on branch: <strong>{currentBranchName}</strong>.
</span>
)}
{branchInfoLoading && <span>Checking branch...</span>}
</span>
</div>
{currentBranchName === "master" ? (
<Button
variant="outline"
size="sm"
onClick={handleRenameMasterToMain}
disabled={isRenamingBranch || branchInfoLoading}
>
{isRenamingBranch ? "Renaming..." : "Rename master to main"}
</Button>
) : isAnyCheckoutVersionInProgress && !isCheckingOutVersion ? null : (
<Button
variant="outline"
size="sm"
onClick={handleCheckoutMainBranch}
disabled={isCheckingOutVersion || branchInfoLoading}
>
{isCheckingOutVersion
? "Checking out..."
: "Switch to main branch"}
</Button>
)}
</div>
)}
{/* Why is this pt-0.5? Because the loading bar is h-1 (it always takes space) and we want the vertical spacing to be consistent.*/}
<div className="@container flex items-center justify-between pb-1.5 pt-0.5">
<div className="flex items-center space-x-2">
<Button
onClick={handleNewChat}
variant="ghost"
className="hidden @2xs:flex items-center justify-start gap-2 mx-2 py-3"
>
<PlusCircle size={16} />
<span>New Chat</span>
</Button>
<Button
onClick={onVersionClick}
variant="ghost"
className="hidden @6xs:flex cursor-pointer items-center gap-1 text-sm px-2 py-1 rounded-md"
>
<History size={16} />
{versionsLoading
? "..."
: `Version ${versions.length}${versionPostfix}`}
</Button>
</div>
<button
data-testid="toggle-preview-panel-button"
onClick={onTogglePreview}
className="cursor-pointer p-2 hover:bg-(--background-lightest) rounded-md"
>
{isPreviewOpen ? (
<PanelRightClose size={20} />
) : (
<PanelRightOpen size={20} />
)}
</button>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,302 @@
import type { Message } from "@/ipc/ipc_types";
import {
DyadMarkdownParser,
VanillaMarkdownParser,
} from "./DyadMarkdownParser";
import { motion } from "framer-motion";
import { useStreamChat } from "@/hooks/useStreamChat";
import {
CheckCircle,
XCircle,
Clock,
GitCommit,
Copy,
Check,
Info,
} from "lucide-react";
import { formatDistanceToNow, format } from "date-fns";
import { useVersions } from "@/hooks/useVersions";
import { useAtomValue } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
interface ChatMessageProps {
message: Message;
isLastMessage: boolean;
}
const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
const { isStreaming } = useStreamChat();
const appId = useAtomValue(selectedAppIdAtom);
const { versions: liveVersions } = useVersions(appId);
//handle copy chat
const { copyMessageContent, copied } = useCopyToClipboard();
const handleCopyFormatted = async () => {
await copyMessageContent(message.content);
};
// Find the version that was active when this message was sent
const messageVersion = useMemo(() => {
if (
message.role === "assistant" &&
message.commitHash &&
liveVersions.length
) {
return (
liveVersions.find(
(version) =>
message.commitHash &&
version.oid.slice(0, 7) === message.commitHash.slice(0, 7),
) || null
);
}
return null;
}, [message.commitHash, message.role, liveVersions]);
// handle copy request id
const [copiedRequestId, setCopiedRequestId] = useState(false);
const copiedRequestIdTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
return () => {
if (copiedRequestIdTimeoutRef.current) {
clearTimeout(copiedRequestIdTimeoutRef.current);
}
};
}, []);
// Format the message timestamp
const formatTimestamp = (timestamp: string | Date) => {
const now = new Date();
const messageTime = new Date(timestamp);
const diffInHours =
(now.getTime() - messageTime.getTime()) / (1000 * 60 * 60);
if (diffInHours < 24) {
return formatDistanceToNow(messageTime, { addSuffix: true });
} else {
return format(messageTime, "MMM d, yyyy 'at' h:mm a");
}
};
return (
<div
className={`flex ${
message.role === "assistant" ? "justify-start" : "justify-end"
}`}
>
<div className={`mt-2 w-full max-w-3xl mx-auto group`}>
<div
className={`rounded-lg p-2 ${
message.role === "assistant" ? "" : "ml-24 bg-(--sidebar-accent)"
}`}
>
{message.role === "assistant" &&
!message.content &&
isStreaming &&
isLastMessage ? (
<div className="flex h-6 items-center space-x-2 p-2">
<motion.div
className="h-3 w-3 rounded-full bg-(--primary) dark:bg-blue-500"
animate={{ y: [0, -12, 0] }}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration: 0.4,
ease: "easeOut",
repeatDelay: 1.2,
}}
/>
<motion.div
className="h-3 w-3 rounded-full bg-(--primary) dark:bg-blue-500"
animate={{ y: [0, -12, 0] }}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration: 0.4,
ease: "easeOut",
delay: 0.4,
repeatDelay: 1.2,
}}
/>
<motion.div
className="h-3 w-3 rounded-full bg-(--primary) dark:bg-blue-500"
animate={{ y: [0, -12, 0] }}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration: 0.4,
ease: "easeOut",
delay: 0.8,
repeatDelay: 1.2,
}}
/>
</div>
) : (
<div
className="prose dark:prose-invert prose-headings:mb-2 prose-p:my-1 prose-pre:my-0 max-w-none break-words"
suppressHydrationWarning
>
{message.role === "assistant" ? (
<>
<DyadMarkdownParser content={message.content} />
{isLastMessage && isStreaming && (
<div className="mt-4 ml-4 relative w-5 h-5 animate-spin">
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 w-2 h-2 bg-(--primary) dark:bg-blue-500 rounded-full"></div>
<div className="absolute bottom-0 left-0 w-2 h-2 bg-(--primary) dark:bg-blue-500 rounded-full opacity-80"></div>
<div className="absolute bottom-0 right-0 w-2 h-2 bg-(--primary) dark:bg-blue-500 rounded-full opacity-60"></div>
</div>
)}
</>
) : (
<VanillaMarkdownParser content={message.content} />
)}
</div>
)}
{(message.role === "assistant" && message.content && !isStreaming) ||
message.approvalState ? (
<div
className={`mt-2 flex items-center ${
message.role === "assistant" &&
message.content &&
!isStreaming &&
message.approvalState
? "justify-between"
: ""
} text-xs`}
>
{message.role === "assistant" &&
message.content &&
!isStreaming && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
data-testid="copy-message-button"
onClick={handleCopyFormatted}
className="flex items-center space-x-1 px-2 py-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors duration-200 cursor-pointer"
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
<span className="hidden sm:inline"></span>
</button>
</TooltipTrigger>
<TooltipContent>
{copied ? "Copied!" : "Copy"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{message.approvalState && (
<div className="flex items-center space-x-1">
{message.approvalState === "approved" ? (
<>
<CheckCircle className="h-4 w-4 text-green-500" />
<span>Approved</span>
</>
) : message.approvalState === "rejected" ? (
<>
<XCircle className="h-4 w-4 text-red-500" />
<span>Rejected</span>
</>
) : null}
</div>
)}
</div>
) : null}
</div>
{/* Timestamp and commit info for assistant messages - only visible on hover */}
{message.role === "assistant" && message.createdAt && (
<div className="mt-1 flex flex-wrap items-center justify-start space-x-2 text-xs text-gray-500 dark:text-gray-400 ">
<div className="flex items-center space-x-1">
<Clock className="h-3 w-3" />
<span>{formatTimestamp(message.createdAt)}</span>
</div>
{messageVersion && messageVersion.message && (
<div className="flex items-center space-x-1">
<GitCommit className="h-3 w-3" />
{messageVersion && messageVersion.message && (
<Tooltip>
<TooltipTrigger asChild>
<span className="max-w-50 truncate font-medium">
{
messageVersion.message
.replace(/^\[dyad\]\s*/i, "")
.split("\n")[0]
}
</span>
</TooltipTrigger>
<TooltipContent>{messageVersion.message}</TooltipContent>
</Tooltip>
)}
</div>
)}
{message.requestId && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
if (!message.requestId) return;
navigator.clipboard
.writeText(message.requestId)
.then(() => {
setCopiedRequestId(true);
if (copiedRequestIdTimeoutRef.current) {
clearTimeout(copiedRequestIdTimeoutRef.current);
}
copiedRequestIdTimeoutRef.current = setTimeout(
() => setCopiedRequestId(false),
2000,
);
})
.catch(() => {
// noop
});
}}
className="flex items-center space-x-1 px-1 py-0.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors duration-200 cursor-pointer"
>
{copiedRequestId ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
<span className="text-xs">
{copiedRequestId ? "Copied" : "Request ID"}
</span>
</button>
</TooltipTrigger>
<TooltipContent>
{copiedRequestId
? "Copied!"
: `Copy Request ID: ${message.requestId.slice(0, 8)}...`}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isLastMessage && message.totalTokens && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-1 px-1 py-0.5">
<Info className="h-3 w-3" />
</div>
</TooltipTrigger>
<TooltipContent>
Max tokens used: {message.totalTokens.toLocaleString()}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
</div>
</div>
);
};
export default ChatMessage;

View File

@@ -0,0 +1,145 @@
import React, { useState, useEffect, memo, type ReactNode } from "react";
import ShikiHighlighter, {
isInlineCode,
createHighlighterCore,
createJavaScriptRegexEngine,
} from "react-shiki/core";
import type { Element as HastElement } from "hast";
import { useTheme } from "../../contexts/ThemeContext";
import { Copy, Check } from "lucide-react";
import github from "@shikijs/themes/github-light-default";
import githubDark from "@shikijs/themes/github-dark-default";
// common languages
import astro from "@shikijs/langs/astro";
import css from "@shikijs/langs/css";
import graphql from "@shikijs/langs/graphql";
import html from "@shikijs/langs/html";
import java from "@shikijs/langs/java";
import javascript from "@shikijs/langs/javascript";
import json from "@shikijs/langs/json";
import jsx from "@shikijs/langs/jsx";
import less from "@shikijs/langs/less";
import markdown from "@shikijs/langs/markdown";
import python from "@shikijs/langs/python";
import sass from "@shikijs/langs/sass";
import scss from "@shikijs/langs/scss";
import shell from "@shikijs/langs/shell";
import sql from "@shikijs/langs/sql";
import tsx from "@shikijs/langs/tsx";
import typescript from "@shikijs/langs/typescript";
import vue from "@shikijs/langs/vue";
type HighlighterCore = Awaited<ReturnType<typeof createHighlighterCore>>;
// Create a singleton highlighter instance
let highlighterPromise: Promise<HighlighterCore> | null = null;
function getHighlighter(): Promise<HighlighterCore> {
if (!highlighterPromise) {
highlighterPromise = createHighlighterCore({
themes: [github, githubDark],
langs: [
astro,
css,
graphql,
html,
java,
javascript,
json,
jsx,
less,
markdown,
python,
sass,
scss,
shell,
sql,
tsx,
typescript,
vue,
],
engine: createJavaScriptRegexEngine(),
});
}
return highlighterPromise as Promise<HighlighterCore>;
}
function useHighlighter() {
const [highlighter, setHighlighter] = useState<HighlighterCore>();
useEffect(() => {
getHighlighter().then(setHighlighter);
}, []);
return highlighter;
}
interface CodeHighlightProps {
className?: string | undefined;
children?: ReactNode | undefined;
node?: HastElement | undefined;
}
export const CodeHighlight = memo(
({ className, children, node, ...props }: CodeHighlightProps) => {
const code = String(children).trim();
const language = className?.match(/language-(\w+)/)?.[1];
const isInline = node ? isInlineCode(node) : false;
//handle copying code to clipboard with transition effect
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000); // revert after 2s
};
const { isDarkMode } = useTheme();
const highlighter = useHighlighter();
return !isInline ? (
<div
className="shiki not-prose relative [&_pre]:overflow-auto
[&_pre]:rounded-lg [&_pre]:px-6 [&_pre]:py-7"
>
{language ? (
<div className="absolute top-2 left-2 right-2 text-xs flex justify-between">
<span className="tracking-tighter text-muted-foreground/85">
{language}
</span>
{code && (
<button
className="mr-2 flex items-center text-xs cursor-pointer"
onClick={handleCopy}
type="button"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
<span className="ml-1">{copied ? "Copied" : "Copy"}</span>
</button>
)}
</div>
) : null}
{highlighter ? (
<ShikiHighlighter
highlighter={highlighter}
language={language}
theme={isDarkMode ? "github-dark-default" : "github-light-default"}
delay={150}
>
{code}
</ShikiHighlighter>
) : (
<pre>
<code>{code}</code>
</pre>
)}
</div>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
(prevProps, nextProps) => {
return prevProps.children === nextProps.children;
},
);

View File

@@ -0,0 +1,89 @@
import { AlertTriangle, ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useSummarizeInNewChat } from "./SummarizeInNewChatButton";
const CONTEXT_LIMIT_THRESHOLD = 40_000;
interface ContextLimitBannerProps {
totalTokens?: number | null;
contextWindow?: number;
}
function formatTokenCount(count: number): string {
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}k`.replace(".0k", "k");
}
return count.toString();
}
export function ContextLimitBanner({
totalTokens,
contextWindow,
}: ContextLimitBannerProps) {
const { handleSummarize } = useSummarizeInNewChat();
// Don't show banner if we don't have the necessary data
if (!totalTokens || !contextWindow) {
return null;
}
// Check if we're within 40k tokens of the context limit
const tokensRemaining = contextWindow - totalTokens;
if (tokensRemaining > CONTEXT_LIMIT_THRESHOLD) {
return null;
}
return (
<div
className="mx-auto max-w-3xl my-3 p-2 rounded-lg border border-amber-500/30 bg-amber-500/10 flex flex-col gap-2"
data-testid="context-limit-banner"
>
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 p-0 hover:bg-transparent text-amber-600 dark:text-amber-400 cursor-help"
>
<AlertTriangle className="h-4 w-4 shrink-0" />
</Button>
</TooltipTrigger>
<TooltipContent className="w-auto p-2 text-xs" side="top">
<div className="grid gap-1">
<div className="flex justify-between gap-4">
<span>Used:</span>
<span className="font-medium">
{formatTokenCount(totalTokens)}
</span>
</div>
<div className="flex justify-between gap-4">
<span>Limit:</span>
<span className="font-medium">
{formatTokenCount(contextWindow)}
</span>
</div>
</div>
</TooltipContent>
</Tooltip>
<p className="text-sm font-medium">
You're close to the context limit for this chat.
</p>
</div>
<Button
onClick={handleSummarize}
variant="outline"
size="sm"
className="h-8 border-amber-500/50 hover:bg-amber-500/20 hover:border-amber-500 text-amber-600 dark:text-amber-400"
>
Summarize into new chat
<ArrowRight className="h-3 w-3 ml-2" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface DeleteChatDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirmDelete: () => void;
chatTitle?: string;
}
export function DeleteChatDialog({
isOpen,
onOpenChange,
onConfirmDelete,
chatTitle,
}: DeleteChatDialogProps) {
return (
<AlertDialog open={isOpen} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Chat</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{chatTitle || "this chat"}"? This
action cannot be undone and all messages in this chat will be
permanently lost.
<br />
<br />
<strong>Note:</strong> Any code changes that have already been
accepted will be kept.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirmDelete}
className="bg-red-600 text-white hover:bg-red-700 dark:bg-red-600 dark:text-white dark:hover:bg-red-700"
>
Delete Chat
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,18 @@
import { Paperclip } from "lucide-react";
interface DragDropOverlayProps {
isDraggingOver: boolean;
}
export function DragDropOverlay({ isDraggingOver }: DragDropOverlayProps) {
if (!isDraggingOver) return null;
return (
<div className="absolute inset-0 bg-blue-100/30 dark:bg-blue-900/30 flex items-center justify-center rounded-lg z-10 pointer-events-none">
<div className="bg-background p-4 rounded-lg shadow-lg text-center">
<Paperclip className="mx-auto mb-2 text-blue-500" />
<p className="text-sm font-medium">Drop files to attach</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
import type React from "react";
import type { ReactNode } from "react";
import { useState } from "react";
import { IpcClient } from "../../ipc/ipc_client";
import { Package, ChevronsUpDown, ChevronsDownUp } from "lucide-react";
import { CodeHighlight } from "./CodeHighlight";
interface DyadAddDependencyProps {
children?: ReactNode;
node?: any;
packages?: string;
}
export const DyadAddDependency: React.FC<DyadAddDependencyProps> = ({
children,
node,
}) => {
// Extract package attribute from the node if available
const packages = node?.properties?.packages?.split(" ") || "";
const [isContentVisible, setIsContentVisible] = useState(false);
const hasChildren = !!children;
return (
<div
className={`bg-(--background-lightest) dark:bg-gray-900 hover:bg-(--background-lighter) rounded-lg px-4 py-3 border my-2 border-border ${
hasChildren ? "cursor-pointer" : ""
}`}
onClick={
hasChildren ? () => setIsContentVisible(!isContentVisible) : undefined
}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Package size={18} className="text-gray-600 dark:text-gray-400" />
{packages.length > 0 && (
<div className="text-gray-800 dark:text-gray-200 font-semibold text-base">
<div className="font-normal">
Do you want to install these packages?
</div>{" "}
<div className="flex flex-wrap gap-2 mt-2">
{packages.map((p: string) => (
<span
className="cursor-pointer text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
key={p}
onClick={() => {
IpcClient.getInstance().openExternalUrl(
`https://www.npmjs.com/package/${p}`,
);
}}
>
{p}
</span>
))}
</div>
</div>
)}
</div>
{hasChildren && (
<div className="flex items-center">
{isContentVisible ? (
<ChevronsDownUp
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
) : (
<ChevronsUpDown
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
)}
</div>
)}
</div>
{packages.length > 0 && (
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">
Make sure these packages are what you want.{" "}
</div>
)}
{/* Show content if it's visible and has children */}
{isContentVisible && hasChildren && (
<div className="mt-2">
<div className="text-xs">
<CodeHighlight className="language-shell">{children}</CodeHighlight>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,89 @@
import React from "react";
import { useNavigate } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useAtomValue } from "jotai";
import { showError } from "@/lib/toast";
import { useLoadApp } from "@/hooks/useLoadApp";
interface DyadAddIntegrationProps {
node: {
properties: {
provider: string;
};
};
children: React.ReactNode;
}
export const DyadAddIntegration: React.FC<DyadAddIntegrationProps> = ({
node,
children,
}) => {
const navigate = useNavigate();
const { provider } = node.properties;
const appId = useAtomValue(selectedAppIdAtom);
const { app } = useLoadApp(appId);
const handleSetupClick = () => {
if (!appId) {
showError("No app ID found");
return;
}
navigate({ to: "/app-details", search: { appId } });
};
if (app?.supabaseProjectName) {
return (
<div className="flex flex-col my-2 p-3 border border-green-300 rounded-lg bg-green-50 shadow-sm">
<div className="flex items-center space-x-2">
<svg
className="w-5 h-5 text-green-600"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="2"
fill="#bbf7d0"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12l2 2 4-4"
/>
</svg>
<span className="font-semibold text-green-800">
Supabase integration complete
</span>
</div>
<div className="text-sm text-green-900">
<p>
This app is connected to Supabase project:{" "}
<span className="font-mono font-medium bg-green-100 px-1 py-0.5 rounded">
{app.supabaseProjectName}
</span>
</p>
<p>Click the chat suggestion "Keep going" to continue.</p>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-2 my-2 p-3 border rounded-md bg-secondary/10">
<div className="text-sm">
<div className="font-medium">Integrate with {provider}?</div>
<div className="text-muted-foreground text-xs">{children}</div>
</div>
<Button onClick={handleSetupClick} className="self-start w-full">
Set up {provider}
</Button>
</div>
);
};

View File

@@ -0,0 +1,31 @@
import type React from "react";
import type { ReactNode } from "react";
import { FileCode } from "lucide-react";
interface DyadCodeSearchProps {
children?: ReactNode;
node?: any;
query?: string;
}
export const DyadCodeSearch: React.FC<DyadCodeSearchProps> = ({
children,
node: _node,
query: queryProp,
}) => {
const query = queryProp || (typeof children === "string" ? children : "");
return (
<div className="bg-(--background-lightest) rounded-lg px-4 py-2 border my-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileCode size={16} className="text-purple-600" />
<div className="text-xs text-purple-600 font-medium">Code Search</div>
</div>
</div>
<div className="text-sm italic text-gray-600 dark:text-gray-300 mt-2">
{query || children}
</div>
</div>
);
};

View File

@@ -0,0 +1,123 @@
import React, { useState, useMemo } from "react";
import { ChevronDown, ChevronUp, FileCode, FileText } from "lucide-react";
interface DyadCodeSearchResultProps {
node?: any;
children?: React.ReactNode;
}
export const DyadCodeSearchResult: React.FC<DyadCodeSearchResultProps> = ({
children,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
// Parse file paths from children content
const files = useMemo(() => {
if (typeof children !== "string") {
return [];
}
const filePaths: string[] = [];
const lines = children.split("\n");
for (const line of lines) {
const trimmedLine = line.trim();
// Skip empty lines and lines that look like tags
if (
trimmedLine &&
!trimmedLine.startsWith("<") &&
!trimmedLine.startsWith(">")
) {
filePaths.push(trimmedLine);
}
}
return filePaths;
}, [children]);
return (
<div
className="relative bg-(--background-lightest) dark:bg-zinc-900 hover:bg-(--background-lighter) rounded-lg px-4 py-2 border border-border my-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
role="button"
aria-expanded={isExpanded}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setIsExpanded(!isExpanded);
}
}}
>
{/* Top-left label badge */}
<div
className="absolute top-2 left-2 flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold text-purple-600 bg-white dark:bg-zinc-900"
style={{ zIndex: 1 }}
>
<FileCode size={16} className="text-purple-600" />
<span>Code Search Result</span>
</div>
{/* File count when collapsed */}
{files.length > 0 && (
<div className="absolute top-2 left-44 flex items-center">
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-zinc-800 text-xs rounded text-gray-600 dark:text-gray-300">
Found {files.length} file{files.length !== 1 ? "s" : ""}
</span>
</div>
)}
{/* Indicator icon */}
<div className="absolute top-2 right-2 p-1 text-gray-500">
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
{/* Main content with smooth transition */}
<div
className="pt-6 overflow-hidden transition-all duration-300 ease-in-out"
style={{
maxHeight: isExpanded ? "1000px" : "0px",
opacity: isExpanded ? 1 : 0,
marginBottom: isExpanded ? "0" : "-6px",
}}
>
{/* File list when expanded */}
{files.length > 0 && (
<div className="mb-3">
<div className="flex flex-wrap gap-2 mt-2">
{files.map((file, index) => {
const filePath = file.trim();
const fileName = filePath.split("/").pop() || filePath;
const pathPart =
filePath.substring(0, filePath.length - fileName.length) ||
"";
return (
<div
key={index}
className="px-2 py-1 bg-gray-100 dark:bg-zinc-800 rounded-lg"
>
<div className="flex items-center gap-1.5">
<FileText
size={14}
className="text-gray-500 dark:text-gray-400 flex-shrink-0"
/>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
{fileName}
</div>
</div>
{pathPart && (
<div className="text-xs text-gray-500 dark:text-gray-400 ml-5">
{pathPart}
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,117 @@
import React, { useState, useEffect } from "react";
import { ChevronUp, ChevronDown, Code2, FileText } from "lucide-react";
import { CustomTagState } from "./stateTypes";
interface DyadCodebaseContextProps {
children: React.ReactNode;
node?: {
properties?: {
files?: string;
state?: CustomTagState;
};
};
}
export const DyadCodebaseContext: React.FC<DyadCodebaseContextProps> = ({
node,
}) => {
const state = node?.properties?.state as CustomTagState;
const inProgress = state === "pending";
const [isExpanded, setIsExpanded] = useState(inProgress);
const files = node?.properties?.files?.split(",") || [];
// Collapse when transitioning from in-progress to not-in-progress
useEffect(() => {
if (!inProgress && isExpanded) {
setIsExpanded(false);
}
}, [inProgress]);
return (
<div
className={`relative bg-(--background-lightest) dark:bg-zinc-900 hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
inProgress ? "border-blue-500" : "border-border"
}`}
onClick={() => setIsExpanded(!isExpanded)}
role="button"
aria-expanded={isExpanded}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setIsExpanded(!isExpanded);
}
}}
>
{/* Top-left label badge */}
<div
className="absolute top-2 left-2 flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold text-blue-500 bg-white dark:bg-zinc-900"
style={{ zIndex: 1 }}
>
<Code2 size={16} className="text-blue-500" />
<span>Codebase Context</span>
</div>
{/* File count when collapsed */}
{files.length > 0 && (
<div className="absolute top-2 left-40 flex items-center">
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-zinc-800 text-xs rounded text-gray-600 dark:text-gray-300">
Using {files.length} file{files.length !== 1 ? "s" : ""}
</span>
</div>
)}
{/* Indicator icon */}
<div className="absolute top-2 right-2 p-1 text-gray-500">
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
{/* Main content with smooth transition */}
<div
className="pt-6 overflow-hidden transition-all duration-300 ease-in-out"
style={{
maxHeight: isExpanded ? "1000px" : "0px",
opacity: isExpanded ? 1 : 0,
marginBottom: isExpanded ? "0" : "-6px", // Compensate for padding
}}
>
{/* File list when expanded */}
{files.length > 0 && (
<div className="mb-3">
<div className="flex flex-wrap gap-2 mt-2">
{files.map((file, index) => {
const filePath = file.trim();
const fileName = filePath.split("/").pop() || filePath;
const pathPart =
filePath.substring(0, filePath.length - fileName.length) ||
"";
return (
<div
key={index}
className="px-2 py-1 bg-gray-100 dark:bg-zinc-800 rounded-lg"
>
<div className="flex items-center gap-1.5">
<FileText
size={14}
className="text-gray-500 dark:text-gray-400 flex-shrink-0"
/>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
{fileName}
</div>
</div>
{pathPart && (
<div className="text-xs text-gray-500 dark:text-gray-400 ml-5">
{pathPart}
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,45 @@
import type React from "react";
import type { ReactNode } from "react";
import { Trash2 } from "lucide-react";
interface DyadDeleteProps {
children?: ReactNode;
node?: any;
path?: string;
}
export const DyadDelete: React.FC<DyadDeleteProps> = ({
children,
node,
path: pathProp,
}) => {
// Use props directly if provided, otherwise extract from node
const path = pathProp || node?.properties?.path || "";
// Extract filename from path
const fileName = path ? path.split("/").pop() : "";
return (
<div className="bg-(--background-lightest) rounded-lg px-4 py-2 border border-red-500 my-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Trash2 size={16} className="text-red-500" />
{fileName && (
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
{fileName}
</span>
)}
<div className="text-xs text-red-500 font-medium">Delete</div>
</div>
</div>
{path && (
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
{path}
</div>
)}
<div className="text-sm text-gray-600 dark:text-gray-300 mt-2">
{children}
</div>
</div>
);
};

View File

@@ -0,0 +1,113 @@
import type React from "react";
import type { ReactNode } from "react";
import { useState } from "react";
import {
ChevronsDownUp,
ChevronsUpDown,
Loader,
CircleX,
Rabbit,
} from "lucide-react";
import { CodeHighlight } from "./CodeHighlight";
import { CustomTagState } from "./stateTypes";
interface DyadEditProps {
children?: ReactNode;
node?: any;
path?: string;
description?: string;
}
export const DyadEdit: React.FC<DyadEditProps> = ({
children,
node,
path: pathProp,
description: descriptionProp,
}) => {
const [isContentVisible, setIsContentVisible] = useState(false);
// Use props directly if provided, otherwise extract from node
const path = pathProp || node?.properties?.path || "";
const description = descriptionProp || node?.properties?.description || "";
const state = node?.properties?.state as CustomTagState;
const inProgress = state === "pending";
const aborted = state === "aborted";
// Extract filename from path
const fileName = path ? path.split("/").pop() : "";
return (
<div
className={`bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
inProgress
? "border-amber-500"
: aborted
? "border-red-500"
: "border-border"
}`}
onClick={() => setIsContentVisible(!isContentVisible)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center">
<Rabbit size={16} />
<span className="bg-blue-500 text-white text-xs px-1.5 py-0.5 rounded ml-1 font-medium">
Turbo Edit
</span>
</div>
{fileName && (
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
{fileName}
</span>
)}
{inProgress && (
<div className="flex items-center text-amber-600 text-xs">
<Loader size={14} className="mr-1 animate-spin" />
<span>Editing...</span>
</div>
)}
{aborted && (
<div className="flex items-center text-red-600 text-xs">
<CircleX size={14} className="mr-1" />
<span>Did not finish</span>
</div>
)}
</div>
<div className="flex items-center">
{isContentVisible ? (
<ChevronsDownUp
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
) : (
<ChevronsUpDown
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
)}
</div>
</div>
{path && (
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
{path}
</div>
)}
{description && (
<div className="text-sm text-gray-600 dark:text-gray-300">
<span className="font-medium">Summary: </span>
{description}
</div>
)}
{isContentVisible && (
<div
className="text-xs cursor-text"
onClick={(e) => e.stopPropagation()}
>
<CodeHighlight className="language-typescript">
{children}
</CodeHighlight>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,85 @@
import type React from "react";
import type { ReactNode } from "react";
import { useState } from "react";
import {
ChevronsDownUp,
ChevronsUpDown,
Database,
Loader,
CircleX,
} from "lucide-react";
import { CodeHighlight } from "./CodeHighlight";
import { CustomTagState } from "./stateTypes";
interface DyadExecuteSqlProps {
children?: ReactNode;
node?: any;
description?: string;
}
export const DyadExecuteSql: React.FC<DyadExecuteSqlProps> = ({
children,
node,
description,
}) => {
const [isContentVisible, setIsContentVisible] = useState(false);
const state = node?.properties?.state as CustomTagState;
const inProgress = state === "pending";
const aborted = state === "aborted";
const queryDescription = description || node?.properties?.description;
return (
<div
className={`bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
inProgress
? "border-amber-500"
: aborted
? "border-red-500"
: "border-border"
}`}
onClick={() => setIsContentVisible(!isContentVisible)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Database size={16} />
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
<span className="font-bold mr-2 outline-2 outline-gray-200 dark:outline-gray-700 bg-gray-100 dark:bg-gray-800 rounded-md px-1">
SQL
</span>
{queryDescription}
</span>
{inProgress && (
<div className="flex items-center text-amber-600 text-xs">
<Loader size={14} className="mr-1 animate-spin" />
<span>Executing...</span>
</div>
)}
{aborted && (
<div className="flex items-center text-red-600 text-xs">
<CircleX size={14} className="mr-1" />
<span>Did not finish</span>
</div>
)}
</div>
<div className="flex items-center">
{isContentVisible ? (
<ChevronsDownUp
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
) : (
<ChevronsUpDown
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
)}
</div>
</div>
{isContentVisible && (
<div className="text-xs">
<CodeHighlight className="language-sql">{children}</CodeHighlight>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,610 @@
import React, { useMemo } from "react";
import ReactMarkdown from "react-markdown";
import { DyadWrite } from "./DyadWrite";
import { DyadRename } from "./DyadRename";
import { DyadDelete } from "./DyadDelete";
import { DyadAddDependency } from "./DyadAddDependency";
import { DyadExecuteSql } from "./DyadExecuteSql";
import { DyadAddIntegration } from "./DyadAddIntegration";
import { DyadEdit } from "./DyadEdit";
import { DyadSearchReplace } from "./DyadSearchReplace";
import { DyadCodebaseContext } from "./DyadCodebaseContext";
import { DyadThink } from "./DyadThink";
import { CodeHighlight } from "./CodeHighlight";
import { useAtomValue } from "jotai";
import { isStreamingByIdAtom, selectedChatIdAtom } from "@/atoms/chatAtoms";
import { CustomTagState } from "./stateTypes";
import { DyadOutput } from "./DyadOutput";
import { DyadProblemSummary } from "./DyadProblemSummary";
import { IpcClient } from "@/ipc/ipc_client";
import { DyadMcpToolCall } from "./DyadMcpToolCall";
import { DyadMcpToolResult } from "./DyadMcpToolResult";
import { DyadWebSearchResult } from "./DyadWebSearchResult";
import { DyadWebSearch } from "./DyadWebSearch";
import { DyadWebCrawl } from "./DyadWebCrawl";
import { DyadCodeSearchResult } from "./DyadCodeSearchResult";
import { DyadCodeSearch } from "./DyadCodeSearch";
import { DyadRead } from "./DyadRead";
import { mapActionToButton } from "./ChatInput";
import { SuggestedAction } from "@/lib/schemas";
import { FixAllErrorsButton } from "./FixAllErrorsButton";
interface DyadMarkdownParserProps {
content: string;
}
type CustomTagInfo = {
tag: string;
attributes: Record<string, string>;
content: string;
fullMatch: string;
inProgress?: boolean;
};
type ContentPiece =
| { type: "markdown"; content: string }
| { type: "custom-tag"; tagInfo: CustomTagInfo };
const customLink = ({
node: _node,
...props
}: {
node?: any;
[key: string]: any;
}) => (
<a
{...props}
onClick={(e) => {
const url = props.href;
if (url) {
e.preventDefault();
IpcClient.getInstance().openExternalUrl(url);
}
}}
/>
);
export const VanillaMarkdownParser = ({ content }: { content: string }) => {
return (
<ReactMarkdown
components={{
code: CodeHighlight,
a: customLink,
}}
>
{content}
</ReactMarkdown>
);
};
/**
* Custom component to parse markdown content with Dyad-specific tags
*/
export const DyadMarkdownParser: React.FC<DyadMarkdownParserProps> = ({
content,
}) => {
const chatId = useAtomValue(selectedChatIdAtom);
const isStreaming = useAtomValue(isStreamingByIdAtom).get(chatId!) ?? false;
// Extract content pieces (markdown and custom tags)
const contentPieces = useMemo(() => {
return parseCustomTags(content);
}, [content]);
// Extract error messages and track positions
const { errorMessages, lastErrorIndex, errorCount } = useMemo(() => {
const errors: string[] = [];
let lastIndex = -1;
let count = 0;
contentPieces.forEach((piece, index) => {
if (
piece.type === "custom-tag" &&
piece.tagInfo.tag === "dyad-output" &&
piece.tagInfo.attributes.type === "error"
) {
const errorMessage = piece.tagInfo.attributes.message;
if (errorMessage?.trim()) {
errors.push(errorMessage.trim());
count++;
lastIndex = index;
}
}
});
return {
errorMessages: errors,
lastErrorIndex: lastIndex,
errorCount: count,
};
}, [contentPieces]);
return (
<>
{contentPieces.map((piece, index) => (
<React.Fragment key={index}>
{piece.type === "markdown"
? piece.content && (
<ReactMarkdown
components={{
code: CodeHighlight,
a: customLink,
}}
>
{piece.content}
</ReactMarkdown>
)
: renderCustomTag(piece.tagInfo, { isStreaming })}
{index === lastErrorIndex &&
errorCount > 1 &&
!isStreaming &&
chatId && (
<div className="mt-3 w-full flex">
<FixAllErrorsButton
errorMessages={errorMessages}
chatId={chatId}
/>
</div>
)}
</React.Fragment>
))}
</>
);
};
/**
* Pre-process content to handle unclosed custom tags
* Adds closing tags at the end of the content for any unclosed custom tags
* Assumes the opening tags are complete and valid
* Returns the processed content and a map of in-progress tags
*/
function preprocessUnclosedTags(content: string): {
processedContent: string;
inProgressTags: Map<string, Set<number>>;
} {
const customTagNames = [
"dyad-write",
"dyad-rename",
"dyad-delete",
"dyad-add-dependency",
"dyad-execute-sql",
"dyad-add-integration",
"dyad-output",
"dyad-problem-report",
"dyad-chat-summary",
"dyad-edit",
"dyad-search-replace",
"dyad-codebase-context",
"dyad-web-search-result",
"dyad-web-search",
"dyad-web-crawl",
"dyad-read",
"think",
"dyad-command",
"dyad-mcp-tool-call",
"dyad-mcp-tool-result",
];
let processedContent = content;
// Map to track which tags are in progress and their positions
const inProgressTags = new Map<string, Set<number>>();
// For each tag type, check if there are unclosed tags
for (const tagName of customTagNames) {
// Count opening and closing tags
const openTagPattern = new RegExp(`<${tagName}(?:\\s[^>]*)?>`, "g");
const closeTagPattern = new RegExp(`</${tagName}>`, "g");
// Track the positions of opening tags
const openingMatches: RegExpExecArray[] = [];
let match;
// Reset regex lastIndex to start from the beginning
openTagPattern.lastIndex = 0;
while ((match = openTagPattern.exec(processedContent)) !== null) {
openingMatches.push({ ...match });
}
const openCount = openingMatches.length;
const closeCount = (processedContent.match(closeTagPattern) || []).length;
// If we have more opening than closing tags
const missingCloseTags = openCount - closeCount;
if (missingCloseTags > 0) {
// Add the required number of closing tags at the end
processedContent += Array(missingCloseTags)
.fill(`</${tagName}>`)
.join("");
// Mark the last N tags as in progress where N is the number of missing closing tags
const inProgressIndexes = new Set<number>();
const startIndex = openCount - missingCloseTags;
for (let i = startIndex; i < openCount; i++) {
inProgressIndexes.add(openingMatches[i].index);
}
inProgressTags.set(tagName, inProgressIndexes);
}
}
return { processedContent, inProgressTags };
}
/**
* Parse the content to extract custom tags and markdown sections into a unified array
*/
function parseCustomTags(content: string): ContentPiece[] {
const { processedContent, inProgressTags } = preprocessUnclosedTags(content);
const customTagNames = [
"dyad-write",
"dyad-rename",
"dyad-delete",
"dyad-add-dependency",
"dyad-execute-sql",
"dyad-add-integration",
"dyad-output",
"dyad-problem-report",
"dyad-chat-summary",
"dyad-edit",
"dyad-search-replace",
"dyad-codebase-context",
"dyad-web-search-result",
"dyad-web-search",
"dyad-web-crawl",
"dyad-code-search-result",
"dyad-code-search",
"dyad-read",
"think",
"dyad-command",
"dyad-mcp-tool-call",
"dyad-mcp-tool-result",
];
const tagPattern = new RegExp(
`<(${customTagNames.join("|")})\\s*([^>]*)>(.*?)<\\/\\1>`,
"gs",
);
const contentPieces: ContentPiece[] = [];
let lastIndex = 0;
let match;
// Find all custom tags
while ((match = tagPattern.exec(processedContent)) !== null) {
const [fullMatch, tag, attributesStr, tagContent] = match;
const startIndex = match.index;
// Add the markdown content before this tag
if (startIndex > lastIndex) {
contentPieces.push({
type: "markdown",
content: processedContent.substring(lastIndex, startIndex),
});
}
// Parse attributes
const attributes: Record<string, string> = {};
const attrPattern = /(\w+)="([^"]*)"/g;
let attrMatch;
while ((attrMatch = attrPattern.exec(attributesStr)) !== null) {
attributes[attrMatch[1]] = attrMatch[2];
}
// Check if this tag was marked as in progress
const tagInProgressSet = inProgressTags.get(tag);
const isInProgress = tagInProgressSet?.has(startIndex);
// Add the tag info
contentPieces.push({
type: "custom-tag",
tagInfo: {
tag,
attributes,
content: tagContent,
fullMatch,
inProgress: isInProgress || false,
},
});
lastIndex = startIndex + fullMatch.length;
}
// Add the remaining markdown content
if (lastIndex < processedContent.length) {
contentPieces.push({
type: "markdown",
content: processedContent.substring(lastIndex),
});
}
return contentPieces;
}
function getState({
isStreaming,
inProgress,
}: {
isStreaming?: boolean;
inProgress?: boolean;
}): CustomTagState {
if (!inProgress) {
return "finished";
}
return isStreaming ? "pending" : "aborted";
}
/**
* Render a custom tag based on its type
*/
function renderCustomTag(
tagInfo: CustomTagInfo,
{ isStreaming }: { isStreaming: boolean },
): React.ReactNode {
const { tag, attributes, content, inProgress } = tagInfo;
switch (tag) {
case "dyad-read":
return (
<DyadRead
node={{
properties: {
path: attributes.path || "",
},
}}
>
{content}
</DyadRead>
);
case "dyad-web-search":
return (
<DyadWebSearch
node={{
properties: {},
}}
>
{content}
</DyadWebSearch>
);
case "dyad-web-crawl":
return (
<DyadWebCrawl
node={{
properties: {},
}}
>
{content}
</DyadWebCrawl>
);
case "dyad-code-search":
return (
<DyadCodeSearch
node={{
properties: {},
}}
>
{content}
</DyadCodeSearch>
);
case "dyad-code-search-result":
return (
<DyadCodeSearchResult
node={{
properties: {},
}}
>
{content}
</DyadCodeSearchResult>
);
case "dyad-web-search-result":
return (
<DyadWebSearchResult
node={{
properties: {
state: getState({ isStreaming, inProgress }),
},
}}
>
{content}
</DyadWebSearchResult>
);
case "think":
return (
<DyadThink
node={{
properties: {
state: getState({ isStreaming, inProgress }),
},
}}
>
{content}
</DyadThink>
);
case "dyad-write":
return (
<DyadWrite
node={{
properties: {
path: attributes.path || "",
description: attributes.description || "",
state: getState({ isStreaming, inProgress }),
},
}}
>
{content}
</DyadWrite>
);
case "dyad-rename":
return (
<DyadRename
node={{
properties: {
from: attributes.from || "",
to: attributes.to || "",
},
}}
>
{content}
</DyadRename>
);
case "dyad-delete":
return (
<DyadDelete
node={{
properties: {
path: attributes.path || "",
},
}}
>
{content}
</DyadDelete>
);
case "dyad-add-dependency":
return (
<DyadAddDependency
node={{
properties: {
packages: attributes.packages || "",
},
}}
>
{content}
</DyadAddDependency>
);
case "dyad-execute-sql":
return (
<DyadExecuteSql
node={{
properties: {
state: getState({ isStreaming, inProgress }),
description: attributes.description || "",
},
}}
>
{content}
</DyadExecuteSql>
);
case "dyad-add-integration":
return (
<DyadAddIntegration
node={{
properties: {
provider: attributes.provider || "",
},
}}
>
{content}
</DyadAddIntegration>
);
case "dyad-edit":
return (
<DyadEdit
node={{
properties: {
path: attributes.path || "",
description: attributes.description || "",
state: getState({ isStreaming, inProgress }),
},
}}
>
{content}
</DyadEdit>
);
case "dyad-search-replace":
return (
<DyadSearchReplace
node={{
properties: {
path: attributes.path || "",
description: attributes.description || "",
state: getState({ isStreaming, inProgress }),
},
}}
>
{content}
</DyadSearchReplace>
);
case "dyad-codebase-context":
return (
<DyadCodebaseContext
node={{
properties: {
files: attributes.files || "",
state: getState({ isStreaming, inProgress }),
},
}}
>
{content}
</DyadCodebaseContext>
);
case "dyad-mcp-tool-call":
return (
<DyadMcpToolCall
node={{
properties: {
serverName: attributes.server || "",
toolName: attributes.tool || "",
},
}}
>
{content}
</DyadMcpToolCall>
);
case "dyad-mcp-tool-result":
return (
<DyadMcpToolResult
node={{
properties: {
serverName: attributes.server || "",
toolName: attributes.tool || "",
},
}}
>
{content}
</DyadMcpToolResult>
);
case "dyad-output":
return (
<DyadOutput
type={attributes.type as "warning" | "error"}
message={attributes.message}
>
{content}
</DyadOutput>
);
case "dyad-problem-report":
return (
<DyadProblemSummary summary={attributes.summary}>
{content}
</DyadProblemSummary>
);
case "dyad-chat-summary":
// Don't render anything for dyad-chat-summary
return null;
case "dyad-command":
if (attributes.type) {
const action = {
id: attributes.type,
} as SuggestedAction;
return <>{mapActionToButton(action)}</>;
}
return null;
default:
return null;
}
}

View File

@@ -0,0 +1,73 @@
import React, { useMemo, useState } from "react";
import { Wrench, ChevronsUpDown, ChevronsDownUp } from "lucide-react";
import { CodeHighlight } from "./CodeHighlight";
interface DyadMcpToolCallProps {
node?: any;
children?: React.ReactNode;
}
export const DyadMcpToolCall: React.FC<DyadMcpToolCallProps> = ({
node,
children,
}) => {
const serverName: string = node?.properties?.serverName || "";
const toolName: string = node?.properties?.toolName || "";
const [expanded, setExpanded] = useState(false);
const raw = typeof children === "string" ? children : String(children ?? "");
const prettyJson = useMemo(() => {
if (!expanded) return "";
try {
const parsed = JSON.parse(raw);
return JSON.stringify(parsed, null, 2);
} catch (e) {
console.error("Error parsing JSON for dyad-mcp-tool-call", e);
return raw;
}
}, [expanded, raw]);
return (
<div
className="relative bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer"
onClick={() => setExpanded((v) => !v)}
>
{/* Top-left label badge */}
<div
className="absolute top-3 left-2 flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold text-blue-600 bg-white dark:bg-zinc-900"
style={{ zIndex: 1 }}
>
<Wrench size={16} className="text-blue-600" />
<span>Tool Call</span>
</div>
{/* Right chevron */}
<div className="absolute top-2 right-2 p-1 text-gray-500">
{expanded ? <ChevronsDownUp size={18} /> : <ChevronsUpDown size={18} />}
</div>
{/* Header content */}
<div className="flex items-start gap-2 pl-24 pr-8 py-1">
{serverName ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 dark:bg-zinc-800 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-zinc-700">
{serverName}
</span>
) : null}
{toolName ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-gray-200 border border-border">
{toolName}
</span>
) : null}
{/* Intentionally no preview or content when collapsed */}
</div>
{/* JSON content */}
{expanded ? (
<div className="mt-2 pr-4 pb-2">
<CodeHighlight className="language-json">{prettyJson}</CodeHighlight>
</div>
) : null}
</div>
);
};

View File

@@ -0,0 +1,73 @@
import React, { useMemo, useState } from "react";
import { CheckCircle, ChevronsUpDown, ChevronsDownUp } from "lucide-react";
import { CodeHighlight } from "./CodeHighlight";
interface DyadMcpToolResultProps {
node?: any;
children?: React.ReactNode;
}
export const DyadMcpToolResult: React.FC<DyadMcpToolResultProps> = ({
node,
children,
}) => {
const serverName: string = node?.properties?.serverName || "";
const toolName: string = node?.properties?.toolName || "";
const [expanded, setExpanded] = useState(false);
const raw = typeof children === "string" ? children : String(children ?? "");
const prettyJson = useMemo(() => {
if (!expanded) return "";
try {
const parsed = JSON.parse(raw);
return JSON.stringify(parsed, null, 2);
} catch (e) {
console.error("Error parsing JSON for dyad-mcp-tool-result", e);
return raw;
}
}, [expanded, raw]);
return (
<div
className="relative bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer"
onClick={() => setExpanded((v) => !v)}
>
{/* Top-left label badge */}
<div
className="absolute top-3 left-2 flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold text-emerald-600 bg-white dark:bg-zinc-900"
style={{ zIndex: 1 }}
>
<CheckCircle size={16} className="text-emerald-600" />
<span>Tool Result</span>
</div>
{/* Right chevron */}
<div className="absolute top-2 right-2 p-1 text-gray-500">
{expanded ? <ChevronsDownUp size={18} /> : <ChevronsUpDown size={18} />}
</div>
{/* Header content */}
<div className="flex items-start gap-2 pl-24 pr-8 py-1">
{serverName ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-50 dark:bg-zinc-800 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-zinc-700">
{serverName}
</span>
) : null}
{toolName ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-gray-200 border border-border">
{toolName}
</span>
) : null}
{/* Intentionally no preview or content when collapsed */}
</div>
{/* JSON content */}
{expanded ? (
<div className="mt-2 pr-4 pb-2">
<CodeHighlight className="language-json">{prettyJson}</CodeHighlight>
</div>
) : null}
</div>
);
};

View File

@@ -0,0 +1,112 @@
import React, { useState } from "react";
import {
ChevronsDownUp,
ChevronsUpDown,
AlertTriangle,
XCircle,
Sparkles,
} from "lucide-react";
import { useAtomValue } from "jotai";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useStreamChat } from "@/hooks/useStreamChat";
import { CopyErrorMessage } from "@/components/CopyErrorMessage";
interface DyadOutputProps {
type: "error" | "warning";
message?: string;
children?: React.ReactNode;
}
export const DyadOutput: React.FC<DyadOutputProps> = ({
type,
message,
children,
}) => {
const [isContentVisible, setIsContentVisible] = useState(false);
const selectedChatId = useAtomValue(selectedChatIdAtom);
const { streamMessage } = useStreamChat();
// If the type is not warning, it is an error (in case LLM gives a weird "type")
const isError = type !== "warning";
const borderColor = isError ? "border-red-500" : "border-amber-500";
const iconColor = isError ? "text-red-500" : "text-amber-500";
const icon = isError ? (
<XCircle size={16} className={iconColor} />
) : (
<AlertTriangle size={16} className={iconColor} />
);
const label = isError ? "Error" : "Warning";
const handleAIFix = (e: React.MouseEvent) => {
e.stopPropagation();
if (message && selectedChatId) {
streamMessage({
prompt: `Fix the error: ${message}`,
chatId: selectedChatId,
});
}
};
return (
<div
className={`relative bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer min-h-18 ${borderColor}`}
onClick={() => setIsContentVisible(!isContentVisible)}
>
{/* Top-left label badge */}
<div
className={`absolute top-2 left-2 flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold ${iconColor} bg-white dark:bg-gray-900`}
style={{ zIndex: 1 }}
>
{icon}
<span>{label}</span>
</div>
{/* Main content, padded to avoid label */}
<div className="flex items-center justify-between pl-24 pr-6">
<div className="flex items-center gap-2">
{message && (
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
{message.slice(0, isContentVisible ? undefined : 100) +
(!isContentVisible ? "..." : "")}
</span>
)}
</div>
<div className="flex items-center">
{isContentVisible ? (
<ChevronsDownUp
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
) : (
<ChevronsUpDown
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
)}
</div>
</div>
{/* Content area */}
{isContentVisible && children && (
<div className="mt-4 pl-20 text-sm text-gray-800 dark:text-gray-200">
{children}
</div>
)}
{/* Action buttons at the bottom - always visible for errors */}
{isError && message && (
<div className="mt-3 px-6 flex justify-end gap-2">
<CopyErrorMessage
errorMessage={children ? `${message}\n${children}` : message}
/>
<button
onClick={handleAIFix}
className="cursor-pointer flex items-center justify-center bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 text-white rounded text-xs px-2 py-1 h-6"
>
<Sparkles size={14} className="mr-1" />
<span>Fix with AI</span>
</button>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,156 @@
import React, { useState } from "react";
import {
ChevronsDownUp,
ChevronsUpDown,
AlertTriangle,
FileText,
} from "lucide-react";
import type { Problem } from "@/ipc/ipc_types";
type ProblemWithoutSnippet = Omit<Problem, "snippet">;
interface DyadProblemSummaryProps {
summary?: string;
children?: React.ReactNode;
}
interface ProblemItemProps {
problem: ProblemWithoutSnippet;
index: number;
}
const ProblemItem: React.FC<ProblemItemProps> = ({ problem, index }) => {
return (
<div className="flex items-start gap-3 py-2 px-3 border-b border-gray-200 dark:border-gray-700 last:border-b-0">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mt-0.5">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
{index + 1}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<FileText size={14} className="text-gray-500 flex-shrink-0" />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{problem.file}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{problem.line}:{problem.column}
</span>
<span className="text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded text-gray-600 dark:text-gray-300">
TS{problem.code}
</span>
</div>
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
{problem.message}
</p>
</div>
</div>
);
};
export const DyadProblemSummary: React.FC<DyadProblemSummaryProps> = ({
summary,
children,
}) => {
const [isContentVisible, setIsContentVisible] = useState(false);
// Parse problems from children content if available
const problems: ProblemWithoutSnippet[] = React.useMemo(() => {
if (!children || typeof children !== "string") return [];
// Parse structured format with <problem> tags
const problemTagRegex =
/<problem\s+file="([^"]+)"\s+line="(\d+)"\s+column="(\d+)"\s+code="(\d+)">([^<]+)<\/problem>/g;
const problems: ProblemWithoutSnippet[] = [];
let match;
while ((match = problemTagRegex.exec(children)) !== null) {
try {
problems.push({
file: match[1],
line: parseInt(match[2], 10),
column: parseInt(match[3], 10),
message: match[5].trim(),
code: parseInt(match[4], 10),
});
} catch {
return [
{
file: "unknown",
line: 0,
column: 0,
message: children,
code: 0,
},
];
}
}
return problems;
}, [children]);
const totalProblems = problems.length;
const displaySummary =
summary || `${totalProblems} problems found (TypeScript errors)`;
return (
<div
className="bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border border-border my-2 cursor-pointer"
onClick={() => setIsContentVisible(!isContentVisible)}
data-testid="problem-summary"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertTriangle
size={16}
className="text-amber-600 dark:text-amber-500"
/>
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
<span className="font-bold mr-2 outline-2 outline-amber-200 dark:outline-amber-700 bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-200 rounded-md px-1">
Auto-fix
</span>
{displaySummary}
</span>
</div>
<div className="flex items-center">
{isContentVisible ? (
<ChevronsDownUp
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
) : (
<ChevronsUpDown
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
)}
</div>
</div>
{/* Content area - show individual problems */}
{isContentVisible && totalProblems > 0 && (
<div className="mt-4">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
{problems.map((problem, index) => (
<ProblemItem
key={`${problem.file}-${problem.line}-${problem.column}-${index}`}
problem={problem}
index={index}
/>
))}
</div>
</div>
)}
{/* Fallback content area for raw children */}
{isContentVisible && totalProblems === 0 && children && (
<div className="mt-4 text-sm text-gray-800 dark:text-gray-200">
<pre className="whitespace-pre-wrap font-mono text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded">
{children}
</pre>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,44 @@
import type React from "react";
import type { ReactNode } from "react";
import { FileText } from "lucide-react";
interface DyadReadProps {
children?: ReactNode;
node?: any;
path?: string;
}
export const DyadRead: React.FC<DyadReadProps> = ({
children,
node,
path: pathProp,
}) => {
const path = pathProp || node?.properties?.path || "";
const fileName = path ? path.split("/").pop() : "";
return (
<div className="bg-(--background-lightest) rounded-lg px-4 py-2 border border-border my-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText size={16} className="text-gray-600" />
{fileName && (
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
{fileName}
</span>
)}
<div className="text-xs text-gray-600 font-medium">Read</div>
</div>
</div>
{path && (
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
{path}
</div>
)}
{children && (
<div className="text-sm text-gray-600 dark:text-gray-300 mt-2">
{children}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,61 @@
import type React from "react";
import type { ReactNode } from "react";
import { FileEdit } from "lucide-react";
interface DyadRenameProps {
children?: ReactNode;
node?: any;
from?: string;
to?: string;
}
export const DyadRename: React.FC<DyadRenameProps> = ({
children,
node,
from: fromProp,
to: toProp,
}) => {
// Use props directly if provided, otherwise extract from node
const from = fromProp || node?.properties?.from || "";
const to = toProp || node?.properties?.to || "";
// Extract filenames from paths
const fromFileName = from ? from.split("/").pop() : "";
const toFileName = to ? to.split("/").pop() : "";
return (
<div className="bg-(--background-lightest) rounded-lg px-4 py-2 border border-amber-500 my-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileEdit size={16} className="text-amber-500" />
{(fromFileName || toFileName) && (
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
{fromFileName && toFileName
? `${fromFileName}${toFileName}`
: fromFileName || toFileName}
</span>
)}
<div className="text-xs text-amber-500 font-medium">Rename</div>
</div>
</div>
{(from || to) && (
<div className="flex flex-col text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
{from && (
<div>
<span className="text-gray-500 dark:text-gray-400">From:</span>{" "}
{from}
</div>
)}
{to && (
<div>
<span className="text-gray-500 dark:text-gray-400">To:</span> {to}
</div>
)}
</div>
)}
<div className="text-sm text-gray-600 dark:text-gray-300 mt-2">
{children}
</div>
</div>
);
};

View File

@@ -0,0 +1,151 @@
import type React from "react";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import {
ChevronsDownUp,
ChevronsUpDown,
Loader,
CircleX,
Search,
ArrowLeftRight,
} from "lucide-react";
import { CodeHighlight } from "./CodeHighlight";
import { CustomTagState } from "./stateTypes";
import { parseSearchReplaceBlocks } from "@/pro/shared/search_replace_parser";
interface DyadSearchReplaceProps {
children?: ReactNode;
node?: any;
path?: string;
description?: string;
}
export const DyadSearchReplace: React.FC<DyadSearchReplaceProps> = ({
children,
node,
path: pathProp,
description: descriptionProp,
}) => {
const [isContentVisible, setIsContentVisible] = useState(false);
const path = pathProp || node?.properties?.path || "";
const description = descriptionProp || node?.properties?.description || "";
const state = node?.properties?.state as CustomTagState;
const inProgress = state === "pending";
const aborted = state === "aborted";
const blocks = useMemo(
() => parseSearchReplaceBlocks(String(children ?? "")),
[children],
);
const fileName = path ? path.split("/").pop() : "";
return (
<div
className={`bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
inProgress
? "border-amber-500"
: aborted
? "border-red-500"
: "border-border"
}`}
onClick={() => setIsContentVisible(!isContentVisible)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center">
<Search size={16} />
<span className="bg-purple-600 text-white text-xs px-1.5 py-0.5 rounded ml-1 font-medium">
Search & Replace
</span>
</div>
{fileName && (
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
{fileName}
</span>
)}
{inProgress && (
<div className="flex items-center text-amber-600 text-xs">
<Loader size={14} className="mr-1 animate-spin" />
<span>Applying changes...</span>
</div>
)}
{aborted && (
<div className="flex items-center text-red-600 text-xs">
<CircleX size={14} className="mr-1" />
<span>Did not finish</span>
</div>
)}
</div>
<div className="flex items-center">
{isContentVisible ? (
<ChevronsDownUp
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
) : (
<ChevronsUpDown
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
)}
</div>
</div>
{path && (
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
{path}
</div>
)}
{description && (
<div className="text-sm text-gray-600 dark:text-gray-300">
<span className="font-medium">Summary: </span>
{description}
</div>
)}
{isContentVisible && (
<div
className="text-xs cursor-text"
onClick={(e) => e.stopPropagation()}
>
{blocks.length === 0 ? (
<CodeHighlight className="language-typescript">
{children}
</CodeHighlight>
) : (
<div className="space-y-3">
{blocks.map((b, i) => (
<div key={i} className="border rounded-lg">
<div className="flex items-center justify-between px-3 py-2 bg-(--background-lighter) rounded-t-lg text-[11px]">
<div className="flex items-center gap-2">
<ArrowLeftRight size={14} />
<span className="font-medium">Change {i + 1}</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-0">
<div className="p-3 border-t md:border-r">
<div className="text-[11px] mb-1 text-muted-foreground font-medium">
Search
</div>
<CodeHighlight className="language-typescript">
{b.searchContent}
</CodeHighlight>
</div>
<div className="p-3 border-t">
<div className="text-[11px] mb-1 text-muted-foreground font-medium">
Replace
</div>
<CodeHighlight className="language-typescript">
{b.replaceContent}
</CodeHighlight>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,96 @@
import React, { useState, useEffect } from "react";
import { Brain, ChevronDown, ChevronUp, Loader } from "lucide-react";
import { VanillaMarkdownParser } from "./DyadMarkdownParser";
import { CustomTagState } from "./stateTypes";
import { DyadTokenSavings } from "./DyadTokenSavings";
interface DyadThinkProps {
node?: any;
children?: React.ReactNode;
}
export const DyadThink: React.FC<DyadThinkProps> = ({ children, node }) => {
const state = node?.properties?.state as CustomTagState;
const inProgress = state === "pending";
const [isExpanded, setIsExpanded] = useState(inProgress);
// Check if content matches token savings format
const tokenSavingsMatch =
typeof children === "string"
? children.match(
/^dyad-token-savings\?original-tokens=([0-9.]+)&smart-context-tokens=([0-9.]+)$/,
)
: null;
// Collapse when transitioning from in-progress to not-in-progress
useEffect(() => {
if (!inProgress && isExpanded) {
setIsExpanded(false);
}
}, [inProgress]);
// If it's token savings format, render DyadTokenSavings component
if (tokenSavingsMatch) {
const originalTokens = parseFloat(tokenSavingsMatch[1]);
const smartContextTokens = parseFloat(tokenSavingsMatch[2]);
return (
<DyadTokenSavings
originalTokens={originalTokens}
smartContextTokens={smartContextTokens}
/>
);
}
return (
<div
className={`relative bg-(--background-lightest) dark:bg-zinc-900 hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
inProgress ? "border-purple-500" : "border-border"
}`}
onClick={() => setIsExpanded(!isExpanded)}
role="button"
aria-expanded={isExpanded}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setIsExpanded(!isExpanded);
}
}}
>
{/* Top-left label badge */}
<div
className="absolute top-2 left-2 flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold text-purple-500 bg-white dark:bg-zinc-900"
style={{ zIndex: 1 }}
>
<Brain size={16} className="text-purple-500" />
<span>Thinking</span>
{inProgress && (
<Loader size={14} className="ml-1 text-purple-500 animate-spin" />
)}
</div>
{/* Indicator icon */}
<div className="absolute top-2 right-2 p-1 text-gray-500">
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
{/* Main content with smooth transition */}
<div
className="pt-6 overflow-hidden transition-all duration-300 ease-in-out"
style={{
maxHeight: isExpanded ? "none" : "0px",
opacity: isExpanded ? 1 : 0,
marginBottom: isExpanded ? "0" : "-6px", // Compensate for padding
}}
>
<div className="px-0 text-sm text-gray-600 dark:text-gray-300">
{typeof children === "string" ? (
<VanillaMarkdownParser content={children} />
) : (
children
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,36 @@
import React from "react";
import { Zap } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
interface DyadTokenSavingsProps {
originalTokens: number;
smartContextTokens: number;
}
export const DyadTokenSavings: React.FC<DyadTokenSavingsProps> = ({
originalTokens,
smartContextTokens,
}) => {
const tokensSaved = originalTokens - smartContextTokens;
const percentageSaved = Math.round((tokensSaved / originalTokens) * 100);
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="bg-green-50 dark:bg-green-950 hover:bg-green-100 dark:hover:bg-green-900 rounded-lg px-4 py-2 border border-green-200 dark:border-green-800 my-2 cursor-pointer">
<div className="flex items-center gap-2 text-green-700 dark:text-green-300">
<Zap size={16} className="text-green-600 dark:text-green-400" />
<span className="text-xs font-medium">
Saved {percentageSaved}% of codebase tokens with Smart Context
</span>
</div>
</div>
</TooltipTrigger>
<TooltipContent side="top" align="center">
<div className="text-left">
Saved {Math.round(tokensSaved).toLocaleString()} tokens
</div>
</TooltipContent>
</Tooltip>
);
};

View File

@@ -0,0 +1,27 @@
import type React from "react";
import type { ReactNode } from "react";
import { ScanQrCode } from "lucide-react";
interface DyadWebCrawlProps {
children?: ReactNode;
node?: any;
}
export const DyadWebCrawl: React.FC<DyadWebCrawlProps> = ({
children,
node: _node,
}) => {
return (
<div className="bg-(--background-lightest) rounded-lg px-4 py-2 border my-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ScanQrCode size={16} className="text-blue-600" />
<div className="text-xs text-blue-600 font-medium">Web Crawl</div>
</div>
</div>
<div className="text-sm italic text-gray-600 dark:text-gray-300 mt-2">
{children}
</div>
</div>
);
};

View File

@@ -0,0 +1,31 @@
import type React from "react";
import type { ReactNode } from "react";
import { Globe } from "lucide-react";
interface DyadWebSearchProps {
children?: ReactNode;
node?: any;
query?: string;
}
export const DyadWebSearch: React.FC<DyadWebSearchProps> = ({
children,
node: _node,
query: queryProp,
}) => {
const query = queryProp || (typeof children === "string" ? children : "");
return (
<div className="bg-(--background-lightest) rounded-lg px-4 py-2 border my-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe size={16} className="text-blue-600" />
<div className="text-xs text-blue-600 font-medium">Web Search</div>
</div>
</div>
<div className="text-sm italic text-gray-600 dark:text-gray-300 mt-2">
{query || children}
</div>
</div>
);
};

View File

@@ -0,0 +1,78 @@
import React, { useEffect, useState } from "react";
import { ChevronDown, ChevronUp, Globe, Loader } from "lucide-react";
import { VanillaMarkdownParser } from "./DyadMarkdownParser";
import { CustomTagState } from "./stateTypes";
interface DyadWebSearchResultProps {
node?: any;
children?: React.ReactNode;
}
export const DyadWebSearchResult: React.FC<DyadWebSearchResultProps> = ({
children,
node,
}) => {
const state = node?.properties?.state as CustomTagState;
const inProgress = state === "pending";
const [isExpanded, setIsExpanded] = useState(inProgress);
// Collapse when transitioning from in-progress to not-in-progress
useEffect(() => {
if (!inProgress && isExpanded) {
setIsExpanded(false);
}
}, [inProgress]);
return (
<div
className={`relative bg-(--background-lightest) dark:bg-zinc-900 hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
inProgress ? "border-blue-500" : "border-border"
}`}
onClick={() => setIsExpanded(!isExpanded)}
role="button"
aria-expanded={isExpanded}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setIsExpanded(!isExpanded);
}
}}
>
{/* Top-left label badge */}
<div
className="absolute top-2 left-2 flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold text-blue-600 bg-white dark:bg-zinc-900"
style={{ zIndex: 1 }}
>
<Globe size={16} className="text-blue-600" />
<span>Web Search Result</span>
{inProgress && (
<Loader size={14} className="ml-1 text-blue-600 animate-spin" />
)}
</div>
{/* Indicator icon */}
<div className="absolute top-2 right-2 p-1 text-gray-500">
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
{/* Main content with smooth transition */}
<div
className="pt-6 overflow-hidden transition-all duration-300 ease-in-out"
style={{
maxHeight: isExpanded ? "none" : "0px",
opacity: isExpanded ? 1 : 0,
marginBottom: isExpanded ? "0" : "-6px",
}}
>
<div className="px-0 text-sm text-gray-600 dark:text-gray-300">
{typeof children === "string" ? (
<VanillaMarkdownParser content={children} />
) : (
children
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,159 @@
import type React from "react";
import type { ReactNode } from "react";
import { useState } from "react";
import {
ChevronsDownUp,
ChevronsUpDown,
Pencil,
Loader,
CircleX,
Edit,
X,
} from "lucide-react";
import { CodeHighlight } from "./CodeHighlight";
import { CustomTagState } from "./stateTypes";
import { FileEditor } from "../preview_panel/FileEditor";
import { useAtomValue } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
interface DyadWriteProps {
children?: ReactNode;
node?: any;
path?: string;
description?: string;
}
export const DyadWrite: React.FC<DyadWriteProps> = ({
children,
node,
path: pathProp,
description: descriptionProp,
}) => {
const [isContentVisible, setIsContentVisible] = useState(false);
// Use props directly if provided, otherwise extract from node
const path = pathProp || node?.properties?.path || "";
const description = descriptionProp || node?.properties?.description || "";
const state = node?.properties?.state as CustomTagState;
const aborted = state === "aborted";
const appId = useAtomValue(selectedAppIdAtom);
const [isEditing, setIsEditing] = useState(false);
const inProgress = state === "pending";
const handleCancel = () => {
setIsEditing(false);
};
const handleEdit = () => {
setIsEditing(true);
setIsContentVisible(true);
};
// Extract filename from path
const fileName = path ? path.split("/").pop() : "";
return (
<div
className={`bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
inProgress
? "border-amber-500"
: aborted
? "border-red-500"
: "border-border"
}`}
onClick={() => setIsContentVisible(!isContentVisible)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Pencil size={16} />
{fileName && (
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
{fileName}
</span>
)}
{inProgress && (
<div className="flex items-center text-amber-600 text-xs">
<Loader size={14} className="mr-1 animate-spin" />
<span>Writing...</span>
</div>
)}
{aborted && (
<div className="flex items-center text-red-600 text-xs">
<CircleX size={14} className="mr-1" />
<span>Did not finish</span>
</div>
)}
</div>
<div className="flex items-center">
{!inProgress && (
<>
{isEditing ? (
<>
<button
onClick={(e) => {
e.stopPropagation();
handleCancel();
}}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-2 py-1 rounded cursor-pointer"
>
<X size={14} />
Cancel
</button>
</>
) : (
<button
onClick={(e) => {
e.stopPropagation();
handleEdit();
}}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-2 py-1 rounded cursor-pointer"
>
<Edit size={14} />
Edit
</button>
)}
</>
)}
{isContentVisible ? (
<ChevronsDownUp
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
) : (
<ChevronsUpDown
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
)}
</div>
</div>
{path && (
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
{path}
</div>
)}
{description && (
<div className="text-sm text-gray-600 dark:text-gray-300">
<span className="font-medium">Summary: </span>
{description}
</div>
)}
{isContentVisible && (
<div
className="text-xs cursor-text"
onClick={(e) => e.stopPropagation()}
>
{isEditing ? (
<div className="h-96 min-h-96 border border-gray-200 dark:border-gray-700 rounded overflow-hidden">
<FileEditor appId={appId ?? null} filePath={path} />
</div>
) : (
<CodeHighlight className="language-typescript">
{children}
</CodeHighlight>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,133 @@
import { Paperclip, MessageSquare, Upload } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { useRef } from "react";
interface FileAttachmentDropdownProps {
onFileSelect: (
files: FileList,
type: "chat-context" | "upload-to-codebase",
) => void;
disabled?: boolean;
className?: string;
}
export function FileAttachmentDropdown({
onFileSelect,
disabled,
className,
}: FileAttachmentDropdownProps) {
const chatContextFileInputRef = useRef<HTMLInputElement>(null);
const uploadToCodebaseFileInputRef = useRef<HTMLInputElement>(null);
const handleChatContextClick = () => {
chatContextFileInputRef.current?.click();
};
const handleUploadToCodebaseClick = () => {
uploadToCodebaseFileInputRef.current?.click();
};
const handleFileChange = (
e: React.ChangeEvent<HTMLInputElement>,
type: "chat-context" | "upload-to-codebase",
) => {
if (e.target.files && e.target.files.length > 0) {
onFileSelect(e.target.files, type);
// Clear the input value so the same file can be selected again
e.target.value = "";
}
};
return (
<>
<TooltipProvider>
<Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={disabled}
title="Attach files"
className={className}
>
<Paperclip size={20} />
</Button>
</TooltipTrigger>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem
onClick={handleChatContextClick}
className="py-3 px-4"
>
<MessageSquare size={16} className="mr-2" />
Attach file as chat context
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="right">
Example use case: screenshot of the app to point out a UI
issue
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem
onClick={handleUploadToCodebaseClick}
className="py-3 px-4"
>
<Upload size={16} className="mr-2" />
Upload file to codebase
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="right">
Example use case: add an image to use for your app
</TooltipContent>
</Tooltip>
</TooltipProvider>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>Attach files</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Hidden file inputs */}
<input
type="file"
data-testid="chat-context-file-input"
ref={chatContextFileInputRef}
onChange={(e) => handleFileChange(e, "chat-context")}
className="hidden"
multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.txt,.md,.js,.ts,.html,.css,.json,.csv"
/>
<input
type="file"
data-testid="upload-to-codebase-file-input"
ref={uploadToCodebaseFileInputRef}
onChange={(e) => handleFileChange(e, "upload-to-codebase")}
className="hidden"
multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.txt,.md,.js,.ts,.html,.css,.json,.csv"
/>
</>
);
}

View File

@@ -0,0 +1,47 @@
import { Button } from "@/components/ui/button";
import { useStreamChat } from "@/hooks/useStreamChat";
import { Sparkles, Loader2 } from "lucide-react";
import { useState } from "react";
interface FixAllErrorsButtonProps {
errorMessages: string[];
chatId: number;
}
export function FixAllErrorsButton({
errorMessages,
chatId,
}: FixAllErrorsButtonProps) {
const { streamMessage } = useStreamChat();
const [isLoading, setIsLoading] = useState(false);
const handleFixAllErrors = () => {
setIsLoading(true);
const allErrors = errorMessages
.map((msg, i) => `${i + 1}. ${msg}`)
.join("\n");
streamMessage({
prompt: `Fix all of the following errors:\n\n${allErrors}`,
chatId,
onSettled: () => setIsLoading(false),
});
};
return (
<Button
variant="outline"
size="sm"
disabled={isLoading}
onClick={handleFixAllErrors}
className="bg-red-50 hover:bg-red-100 dark:bg-red-950 dark:hover:bg-red-900 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800 ml-auto hover:cursor-pointer"
>
{isLoading ? (
<Loader2 size={16} className="mr-1 animate-spin" />
) : (
<Sparkles size={16} className="mr-1" />
)}
Fix All Errors ({errorMessages.length})
</Button>
);
}

Some files were not shown because too many files have changed in this diff Show More