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:
150
backups/backup-20251218-161645/src/components/AppList.tsx
Normal file
150
backups/backup-20251218-161645/src/components/AppList.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
157
backups/backup-20251218-161645/src/components/AppUpgrades.tsx
Normal file
157
backups/backup-20251218-161645/src/components/AppUpgrades.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
303
backups/backup-20251218-161645/src/components/ChatList.tsx
Normal file
303
backups/backup-20251218-161645/src/components/ChatList.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
204
backups/backup-20251218-161645/src/components/ChatPanel.tsx
Normal file
204
backups/backup-20251218-161645/src/components/ChatPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
113
backups/backup-20251218-161645/src/components/ErrorBoundary.tsx
Normal file
113
backups/backup-20251218-161645/src/components/ErrorBoundary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
244
backups/backup-20251218-161645/src/components/HelpBotDialog.tsx
Normal file
244
backups/backup-20251218-161645/src/components/HelpBotDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
482
backups/backup-20251218-161645/src/components/HelpDialog.tsx
Normal file
482
backups/backup-20251218-161645/src/components/HelpDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
136
backups/backup-20251218-161645/src/components/LoadingBlock.tsx
Normal file
136
backups/backup-20251218-161645/src/components/LoadingBlock.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
130
backups/backup-20251218-161645/src/components/McpToolsPicker.tsx
Normal file
130
backups/backup-20251218-161645/src/components/McpToolsPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
624
backups/backup-20251218-161645/src/components/ModelPicker.tsx
Normal file
624
backups/backup-20251218-161645/src/components/ModelPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
backups/backup-20251218-161645/src/components/NeonConnector.tsx
Normal file
158
backups/backup-20251218-161645/src/components/NeonConnector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
110
backups/backup-20251218-161645/src/components/PortalMigrate.tsx
Normal file
110
backups/backup-20251218-161645/src/components/PortalMigrate.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
20
backups/backup-20251218-161645/src/components/PriceBadge.tsx
Normal file
20
backups/backup-20251218-161645/src/components/PriceBadge.tsx
Normal 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;
|
||||
228
backups/backup-20251218-161645/src/components/ProBanner.tsx
Normal file
228
backups/backup-20251218-161645/src/components/ProBanner.tsx
Normal 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 4–10x 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
488
backups/backup-20251218-161645/src/components/SetupBanner.tsx
Normal file
488
backups/backup-20251218-161645/src/components/SetupBanner.tsx
Normal 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
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
163
backups/backup-20251218-161645/src/components/TemplateCard.tsx
Normal file
163
backups/backup-20251218-161645/src/components/TemplateCard.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
231
backups/backup-20251218-161645/src/components/app-sidebar.tsx
Normal file
231
backups/backup-20251218-161645/src/components/app-sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
backups/backup-20251218-161645/src/components/appItem.tsx
Normal file
82
backups/backup-20251218-161645/src/components/appItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
1017
backups/backup-20251218-161645/src/components/chat/ChatInput.tsx
Normal file
1017
backups/backup-20251218-161645/src/components/chat/ChatInput.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
113
backups/backup-20251218-161645/src/components/chat/DyadEdit.tsx
Normal file
113
backups/backup-20251218-161645/src/components/chat/DyadEdit.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
159
backups/backup-20251218-161645/src/components/chat/DyadWrite.tsx
Normal file
159
backups/backup-20251218-161645/src/components/chat/DyadWrite.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user