Click to edit UI (#385)
- [x] add e2e test - happy case (make sure it clears selection and next prompt is empty, and preview is cleared); de-selection case - [x] shim - old & new file - [x] upgrade path - [x] add docs - [x] add try-catch to parser script - [x] make it work for next.js - [x] extract npm package - [x] make sure plugin doesn't apply in prod
This commit is contained in:
6
src/atoms/previewAtoms.ts
Normal file
6
src/atoms/previewAtoms.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ComponentSelection } from "@/ipc/ipc_types";
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const selectedComponentPreviewAtom = atom<ComponentSelection | null>(
|
||||
null,
|
||||
);
|
||||
152
src/components/AppUpgrades.tsx
Normal file
152
src/components/AppUpgrades.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
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: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["app-upgrades", 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>
|
||||
);
|
||||
}
|
||||
@@ -191,6 +191,7 @@ export function ChatHeader({
|
||||
</div>
|
||||
|
||||
<button
|
||||
data-testid="toggle-preview-panel-button"
|
||||
onClick={onTogglePreview}
|
||||
className="cursor-pointer p-2 hover:bg-(--background-lightest) rounded-md"
|
||||
>
|
||||
|
||||
@@ -61,6 +61,9 @@ import { DragDropOverlay } from "./DragDropOverlay";
|
||||
import { showError, showExtraFilesToast } from "@/lib/toast";
|
||||
import { ChatInputControls } from "../ChatInputControls";
|
||||
import { ChatErrorBox } from "./ChatErrorBox";
|
||||
import { selectedComponentPreviewAtom } from "@/atoms/previewAtoms";
|
||||
import { SelectedComponentDisplay } from "./SelectedComponentDisplay";
|
||||
|
||||
const showTokenBarAtom = atom(false);
|
||||
|
||||
export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
@@ -78,6 +81,9 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
const [, setMessages] = useAtom<Message[]>(chatMessagesAtom);
|
||||
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
|
||||
const [showTokenBar, setShowTokenBar] = useAtom(showTokenBarAtom);
|
||||
const [selectedComponent, setSelectedComponent] = useAtom(
|
||||
selectedComponentPreviewAtom,
|
||||
);
|
||||
|
||||
// Use the attachments hook
|
||||
const {
|
||||
@@ -148,6 +154,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
|
||||
const currentInput = inputValue;
|
||||
setInputValue("");
|
||||
setSelectedComponent(null);
|
||||
|
||||
// Send message with attachments and clear them after sending
|
||||
await streamMessage({
|
||||
@@ -155,6 +162,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
chatId,
|
||||
attachments,
|
||||
redo: false,
|
||||
selectedComponent,
|
||||
});
|
||||
clearAttachments();
|
||||
posthog.capture("chat:submit");
|
||||
@@ -282,6 +290,8 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<SelectedComponentDisplay />
|
||||
|
||||
{/* Use the AttachmentsList component */}
|
||||
<AttachmentsList
|
||||
attachments={attachments}
|
||||
|
||||
@@ -20,87 +20,87 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => {
|
||||
message.role === "assistant" ? "justify-start" : "justify-end"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`rounded-lg p-2 mt-2 ${
|
||||
message.role === "assistant"
|
||||
? "w-full max-w-3xl mx-auto"
|
||||
: "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"
|
||||
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.approvalState && (
|
||||
<div className="mt-2 flex items-center justify-end space-x-1 text-xs">
|
||||
{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 className={`mt-2 w-full max-w-3xl mx-auto`}>
|
||||
<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"
|
||||
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.approvalState && (
|
||||
<div className="mt-2 flex items-center justify-end space-x-1 text-xs">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
47
src/components/chat/SelectedComponentDisplay.tsx
Normal file
47
src/components/chat/SelectedComponentDisplay.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { selectedComponentPreviewAtom } from "@/atoms/previewAtoms";
|
||||
import { useAtom } from "jotai";
|
||||
import { Code2, X } from "lucide-react";
|
||||
|
||||
export function SelectedComponentDisplay() {
|
||||
const [selectedComponent, setSelectedComponent] = useAtom(
|
||||
selectedComponentPreviewAtom,
|
||||
);
|
||||
|
||||
if (!selectedComponent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-2 pb-1" data-testid="selected-component-display">
|
||||
<div className="flex items-center justify-between rounded-md bg-indigo-600/10 px-2 py-1 text-sm">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<Code2
|
||||
size={16}
|
||||
className="flex-shrink-0 text-indigo-600 dark:text-indigo-400"
|
||||
/>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<span
|
||||
className="truncate font-medium text-indigo-800 dark:text-indigo-300"
|
||||
title={selectedComponent.name}
|
||||
>
|
||||
{selectedComponent.name}
|
||||
</span>
|
||||
<span
|
||||
className="truncate text-xs text-indigo-600/80 dark:text-indigo-400/80"
|
||||
title={`${selectedComponent.relativePath}:${selectedComponent.lineNumber}`}
|
||||
>
|
||||
{selectedComponent.relativePath}:{selectedComponent.lineNumber}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedComponent(null)}
|
||||
className="ml-2 flex-shrink-0 rounded-full p-0.5 hover:bg-indigo-600/20"
|
||||
title="Deselect component"
|
||||
>
|
||||
<X size={18} className="text-indigo-600 dark:text-indigo-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ChevronDown,
|
||||
Lightbulb,
|
||||
ChevronRight,
|
||||
MousePointerClick,
|
||||
} from "lucide-react";
|
||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
@@ -29,6 +30,14 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import { selectedComponentPreviewAtom } from "@/atoms/previewAtoms";
|
||||
import { ComponentSelection } from "@/ipc/ipc_types";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface ErrorBannerProps {
|
||||
error: string | undefined;
|
||||
@@ -165,11 +174,30 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
}, [routerContent]);
|
||||
|
||||
// Navigation state
|
||||
const [isComponentSelectorInitialized, setIsComponentSelectorInitialized] =
|
||||
useState(false);
|
||||
const [canGoBack, setCanGoBack] = useState(false);
|
||||
const [canGoForward, setCanGoForward] = useState(false);
|
||||
const [navigationHistory, setNavigationHistory] = useState<string[]>([]);
|
||||
const [currentHistoryPosition, setCurrentHistoryPosition] = useState(0);
|
||||
const [selectedComponentPreview, setSelectedComponentPreview] = useAtom(
|
||||
selectedComponentPreviewAtom,
|
||||
);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [isPicking, setIsPicking] = useState(false);
|
||||
|
||||
// Deactivate component selector when selection is cleared
|
||||
useEffect(() => {
|
||||
if (!selectedComponentPreview) {
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{ type: "deactivate-dyad-component-selector" },
|
||||
"*",
|
||||
);
|
||||
}
|
||||
setIsPicking(false);
|
||||
}
|
||||
}, [selectedComponentPreview]);
|
||||
|
||||
// Add message listener for iframe errors and navigation events
|
||||
useEffect(() => {
|
||||
@@ -179,6 +207,18 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data?.type === "dyad-component-selector-initialized") {
|
||||
setIsComponentSelectorInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data?.type === "dyad-component-selected") {
|
||||
console.log("Component picked:", event.data);
|
||||
setSelectedComponentPreview(parseComponentSelection(event.data));
|
||||
setIsPicking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, payload } = event.data as {
|
||||
type:
|
||||
| "window-error"
|
||||
@@ -262,6 +302,8 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
selectedAppId,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
setIsComponentSelectorInitialized,
|
||||
setSelectedComponentPreview,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -280,6 +322,22 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
}
|
||||
}, [appUrl]);
|
||||
|
||||
// Function to activate component selector in the iframe
|
||||
const handleActivateComponentSelector = () => {
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
const newIsPicking = !isPicking;
|
||||
setIsPicking(newIsPicking);
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: newIsPicking
|
||||
? "activate-dyad-component-selector"
|
||||
: "deactivate-dyad-component-selector",
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to navigate back
|
||||
const handleNavigateBack = () => {
|
||||
if (canGoBack && iframeRef.current?.contentWindow) {
|
||||
@@ -371,6 +429,33 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
<div className="flex items-center p-2 border-b space-x-2 ">
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex space-x-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleActivateComponentSelector}
|
||||
className={`p-1 rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isPicking
|
||||
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
|
||||
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900"
|
||||
}`}
|
||||
disabled={
|
||||
loading || !selectedAppId || !isComponentSelectorInitialized
|
||||
}
|
||||
data-testid="preview-pick-element-button"
|
||||
>
|
||||
<MousePointerClick size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{isPicking
|
||||
? "Deactivate component selector"
|
||||
: "Select component"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={!canGoBack || loading || !selectedAppId}
|
||||
@@ -486,3 +571,48 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function parseComponentSelection(data: any): ComponentSelection | null {
|
||||
if (
|
||||
!data ||
|
||||
data.type !== "dyad-component-selected" ||
|
||||
typeof data.id !== "string" ||
|
||||
typeof data.name !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { id, name } = data;
|
||||
|
||||
// The id is expected to be in the format "filepath:line:column"
|
||||
const parts = id.split(":");
|
||||
if (parts.length < 3) {
|
||||
console.error(`Invalid component selection id format: "${id}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const columnStr = parts.pop();
|
||||
const lineStr = parts.pop();
|
||||
const relativePath = parts.join(":");
|
||||
|
||||
if (!columnStr || !lineStr || !relativePath) {
|
||||
console.error(`Could not parse component selection from id: "${id}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const lineNumber = parseInt(lineStr, 10);
|
||||
const columnNumber = parseInt(columnStr, 10);
|
||||
|
||||
if (isNaN(lineNumber) || isNaN(columnNumber)) {
|
||||
console.error(`Could not parse line/column from id: "${id}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
relativePath,
|
||||
lineNumber,
|
||||
columnNumber,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Message } from "@/ipc/ipc_types";
|
||||
import type { ComponentSelection, Message } from "@/ipc/ipc_types";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import {
|
||||
chatErrorAtom,
|
||||
@@ -56,11 +56,13 @@ export function useStreamChat({
|
||||
chatId,
|
||||
redo,
|
||||
attachments,
|
||||
selectedComponent,
|
||||
}: {
|
||||
prompt: string;
|
||||
chatId: number;
|
||||
redo?: boolean;
|
||||
attachments?: File[];
|
||||
selectedComponent?: ComponentSelection | null;
|
||||
}) => {
|
||||
if (
|
||||
(!prompt.trim() && (!attachments || attachments.length === 0)) ||
|
||||
@@ -74,6 +76,7 @@ export function useStreamChat({
|
||||
let hasIncrementedStreamCount = false;
|
||||
try {
|
||||
IpcClient.getInstance().streamMessage(prompt, {
|
||||
selectedComponent: selectedComponent ?? null,
|
||||
chatId,
|
||||
redo,
|
||||
attachments,
|
||||
|
||||
@@ -442,6 +442,8 @@ export function registerAppHandlers() {
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
try {
|
||||
// Kill any orphaned process on port 32100 (in case previous run left it)
|
||||
await killProcessOnPort(32100);
|
||||
await executeApp({ appPath, appId, event });
|
||||
|
||||
return;
|
||||
|
||||
179
src/ipc/handlers/app_upgrade_handlers.ts
Normal file
179
src/ipc/handlers/app_upgrade_handlers.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { createLoggedHandler } from "./safe_handle";
|
||||
import log from "electron-log";
|
||||
import { AppUpgrade } from "../ipc_types";
|
||||
import { db } from "../../db";
|
||||
import { apps } from "../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getDyadAppPath } from "../../paths/paths";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const logger = log.scope("app_upgrade_handlers");
|
||||
const handle = createLoggedHandler(logger);
|
||||
|
||||
const availableUpgrades: Omit<AppUpgrade, "isNeeded">[] = [
|
||||
{
|
||||
id: "component-tagger",
|
||||
title: "Enable select component to edit",
|
||||
description:
|
||||
"Installs the Dyad component tagger Vite plugin and its dependencies.",
|
||||
manualUpgradeUrl: "https://dyad.sh/docs/upgrades/select-component",
|
||||
},
|
||||
];
|
||||
|
||||
async function getApp(appId: number) {
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`);
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
function isComponentTaggerUpgradeNeeded(appPath: string): boolean {
|
||||
const viteConfigPathJs = path.join(appPath, "vite.config.js");
|
||||
const viteConfigPathTs = path.join(appPath, "vite.config.ts");
|
||||
|
||||
let viteConfigPath;
|
||||
if (fs.existsSync(viteConfigPathTs)) {
|
||||
viteConfigPath = viteConfigPathTs;
|
||||
} else if (fs.existsSync(viteConfigPathJs)) {
|
||||
viteConfigPath = viteConfigPathJs;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const viteConfigContent = fs.readFileSync(viteConfigPath, "utf-8");
|
||||
return !viteConfigContent.includes("@dyad-sh/react-vite-component-tagger");
|
||||
} catch (e) {
|
||||
logger.error("Error reading vite config", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyComponentTagger(appPath: string) {
|
||||
const viteConfigPathJs = path.join(appPath, "vite.config.js");
|
||||
const viteConfigPathTs = path.join(appPath, "vite.config.ts");
|
||||
|
||||
let viteConfigPath;
|
||||
if (fs.existsSync(viteConfigPathTs)) {
|
||||
viteConfigPath = viteConfigPathTs;
|
||||
} else if (fs.existsSync(viteConfigPathJs)) {
|
||||
viteConfigPath = viteConfigPathJs;
|
||||
} else {
|
||||
throw new Error("Could not find vite.config.js or vite.config.ts");
|
||||
}
|
||||
|
||||
let content = await fs.promises.readFile(viteConfigPath, "utf-8");
|
||||
|
||||
// Add import statement if not present
|
||||
if (
|
||||
!content.includes(
|
||||
"import dyadComponentTagger from '@dyad-sh/react-vite-component-tagger';",
|
||||
)
|
||||
) {
|
||||
// Add it after the last import statement
|
||||
const lines = content.split("\n");
|
||||
let lastImportIndex = -1;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (lines[i].startsWith("import ")) {
|
||||
lastImportIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
lines.splice(
|
||||
lastImportIndex + 1,
|
||||
0,
|
||||
"import dyadComponentTagger from '@dyad-sh/react-vite-component-tagger';",
|
||||
);
|
||||
content = lines.join("\n");
|
||||
}
|
||||
|
||||
// Add plugin to plugins array
|
||||
if (content.includes("plugins: [")) {
|
||||
if (!content.includes("dyadComponentTagger()")) {
|
||||
content = content.replace(
|
||||
"plugins: [",
|
||||
"plugins: [dyadComponentTagger(), ",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
"Could not find `plugins: [` in vite.config.ts. Manual installation required.",
|
||||
);
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(viteConfigPath, content);
|
||||
|
||||
// Install the dependency
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
logger.info("Installing component-tagger dependency");
|
||||
const process = spawn(
|
||||
"pnpm add -D @dyad-sh/react-vite-component-tagger || npm install --save-dev --legacy-peer-deps @dyad-sh/react-vite-component-tagger",
|
||||
{
|
||||
cwd: appPath,
|
||||
shell: true,
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
process.stdout?.on("data", (data) => logger.info(data.toString()));
|
||||
process.stderr?.on("data", (data) => logger.error(data.toString()));
|
||||
|
||||
process.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
logger.info("component-tagger dependency installed successfully");
|
||||
resolve();
|
||||
} else {
|
||||
logger.error(`Failed to install dependency, exit code ${code}`);
|
||||
reject(new Error("Failed to install dependency"));
|
||||
}
|
||||
});
|
||||
|
||||
process.on("error", (err) => {
|
||||
logger.error("Failed to spawn pnpm", err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function registerAppUpgradeHandlers() {
|
||||
handle(
|
||||
"get-app-upgrades",
|
||||
async (_, { appId }: { appId: number }): Promise<AppUpgrade[]> => {
|
||||
const app = await getApp(appId);
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
|
||||
const upgradesWithStatus = availableUpgrades.map((upgrade) => {
|
||||
let isNeeded = false;
|
||||
if (upgrade.id === "component-tagger") {
|
||||
isNeeded = isComponentTaggerUpgradeNeeded(appPath);
|
||||
}
|
||||
return { ...upgrade, isNeeded };
|
||||
});
|
||||
|
||||
return upgradesWithStatus;
|
||||
},
|
||||
);
|
||||
|
||||
handle(
|
||||
"execute-app-upgrade",
|
||||
async (_, { appId, upgradeId }: { appId: number; upgradeId: string }) => {
|
||||
if (!upgradeId) {
|
||||
throw new Error("upgradeId is required");
|
||||
}
|
||||
|
||||
const app = await getApp(appId);
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
|
||||
if (upgradeId === "component-tagger") {
|
||||
await applyComponentTagger(appPath);
|
||||
} else {
|
||||
throw new Error(`Unknown upgrade id: ${upgradeId}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -160,7 +160,45 @@ export function registerChatStreamHandlers() {
|
||||
}
|
||||
|
||||
// Add user message to database with attachment info
|
||||
const userPrompt = req.prompt + (attachmentInfo ? attachmentInfo : "");
|
||||
let userPrompt = req.prompt + (attachmentInfo ? attachmentInfo : "");
|
||||
if (req.selectedComponent) {
|
||||
let componentSnippet = "[component snippet not available]";
|
||||
try {
|
||||
const componentFileContent = await readFile(
|
||||
path.join(
|
||||
getDyadAppPath(chat.app.path),
|
||||
req.selectedComponent.relativePath,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const lines = componentFileContent.split("\n");
|
||||
const selectedIndex = req.selectedComponent.lineNumber - 1;
|
||||
|
||||
// Let's get one line before and three after for context.
|
||||
const startIndex = Math.max(0, selectedIndex - 1);
|
||||
const endIndex = Math.min(lines.length, selectedIndex + 4);
|
||||
|
||||
const snippetLines = lines.slice(startIndex, endIndex);
|
||||
const selectedLineInSnippetIndex = selectedIndex - startIndex;
|
||||
|
||||
if (snippetLines[selectedLineInSnippetIndex]) {
|
||||
snippetLines[selectedLineInSnippetIndex] =
|
||||
`${snippetLines[selectedLineInSnippetIndex]} // <-- EDIT HERE`;
|
||||
}
|
||||
|
||||
componentSnippet = snippetLines.join("\n");
|
||||
} catch (err) {
|
||||
logger.error(`Error reading selected component file content: ${err}`);
|
||||
}
|
||||
|
||||
userPrompt += `\n\nSelected component: ${req.selectedComponent.name} (file: ${req.selectedComponent.relativePath})
|
||||
|
||||
Snippet:
|
||||
\`\`\`
|
||||
${componentSnippet}
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
await db
|
||||
.insert(messages)
|
||||
.values({
|
||||
@@ -228,7 +266,16 @@ export function registerChatStreamHandlers() {
|
||||
try {
|
||||
const out = await extractCodebase({
|
||||
appPath,
|
||||
chatContext: validateChatContext(updatedChat.app.chatContext),
|
||||
chatContext: req.selectedComponent
|
||||
? {
|
||||
contextPaths: [
|
||||
{
|
||||
globPath: req.selectedComponent.relativePath,
|
||||
},
|
||||
],
|
||||
smartContextAutoIncludes: [],
|
||||
}
|
||||
: validateChatContext(updatedChat.app.chatContext),
|
||||
});
|
||||
codebaseInfo = out.formattedOutput;
|
||||
files = out.files;
|
||||
|
||||
@@ -33,6 +33,8 @@ import type {
|
||||
UserBudgetInfo,
|
||||
CopyAppParams,
|
||||
App,
|
||||
ComponentSelection,
|
||||
AppUpgrade,
|
||||
} from "./ipc_types";
|
||||
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
|
||||
import { showError } from "@/lib/toast";
|
||||
@@ -224,6 +226,7 @@ export class IpcClient {
|
||||
public streamMessage(
|
||||
prompt: string,
|
||||
options: {
|
||||
selectedComponent: ComponentSelection | null;
|
||||
chatId: number;
|
||||
redo?: boolean;
|
||||
attachments?: File[];
|
||||
@@ -232,7 +235,15 @@ export class IpcClient {
|
||||
onError: (error: string) => void;
|
||||
},
|
||||
): void {
|
||||
const { chatId, redo, attachments, onUpdate, onEnd, onError } = options;
|
||||
const {
|
||||
chatId,
|
||||
redo,
|
||||
attachments,
|
||||
selectedComponent,
|
||||
onUpdate,
|
||||
onEnd,
|
||||
onError,
|
||||
} = options;
|
||||
this.chatStreams.set(chatId, { onUpdate, onEnd, onError });
|
||||
|
||||
// Handle file attachments if provided
|
||||
@@ -264,6 +275,7 @@ export class IpcClient {
|
||||
prompt,
|
||||
chatId,
|
||||
redo,
|
||||
selectedComponent,
|
||||
attachments: fileDataArray,
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -284,6 +296,7 @@ export class IpcClient {
|
||||
prompt,
|
||||
chatId,
|
||||
redo,
|
||||
selectedComponent,
|
||||
})
|
||||
.catch((err) => {
|
||||
showError(err);
|
||||
@@ -859,6 +872,19 @@ export class IpcClient {
|
||||
appId: number;
|
||||
chatContext: AppChatContext;
|
||||
}): Promise<void> {
|
||||
return this.ipcRenderer.invoke("set-context-paths", params);
|
||||
await this.ipcRenderer.invoke("set-context-paths", params);
|
||||
}
|
||||
|
||||
public async getAppUpgrades(params: {
|
||||
appId: number;
|
||||
}): Promise<AppUpgrade[]> {
|
||||
return this.ipcRenderer.invoke("get-app-upgrades", params);
|
||||
}
|
||||
|
||||
public async executeAppUpgrade(params: {
|
||||
appId: number;
|
||||
upgradeId: string;
|
||||
}): Promise<void> {
|
||||
return this.ipcRenderer.invoke("execute-app-upgrade", params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { registerImportHandlers } from "./handlers/import_handlers";
|
||||
import { registerSessionHandlers } from "./handlers/session_handlers";
|
||||
import { registerProHandlers } from "./handlers/pro_handlers";
|
||||
import { registerContextPathsHandlers } from "./handlers/context_paths_handlers";
|
||||
import { registerAppUpgradeHandlers } from "./handlers/app_upgrade_handlers";
|
||||
|
||||
export function registerIpcHandlers() {
|
||||
// Register all IPC handlers by category
|
||||
@@ -45,4 +46,5 @@ export function registerIpcHandlers() {
|
||||
registerSessionHandlers();
|
||||
registerProHandlers();
|
||||
registerContextPathsHandlers();
|
||||
registerAppUpgradeHandlers();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface ChatStreamParams {
|
||||
type: string;
|
||||
data: string; // Base64 encoded file data
|
||||
}>;
|
||||
selectedComponent: ComponentSelection | null;
|
||||
}
|
||||
|
||||
export interface ChatResponseEnd {
|
||||
@@ -223,3 +224,19 @@ export const UserBudgetInfoSchema = z.object({
|
||||
budgetResetDate: z.date(),
|
||||
});
|
||||
export type UserBudgetInfo = z.infer<typeof UserBudgetInfoSchema>;
|
||||
|
||||
export interface ComponentSelection {
|
||||
id: string;
|
||||
name: string;
|
||||
relativePath: string;
|
||||
lineNumber: number;
|
||||
columnNumber: number;
|
||||
}
|
||||
|
||||
export interface AppUpgrade {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
manualUpgradeUrl: string;
|
||||
isNeeded: boolean;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import { Loader2 } from "lucide-react";
|
||||
import { invalidateAppQuery } from "@/hooks/useLoadApp";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { useCheckName } from "@/hooks/useCheckName";
|
||||
import { AppUpgrades } from "@/components/AppUpgrades";
|
||||
|
||||
export default function AppDetailsPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -341,6 +342,7 @@ export default function AppDetailsPage() {
|
||||
</Button>
|
||||
<GitHubConnector appId={appId} folderName={selectedApp.path} />
|
||||
{appId && <SupabaseConnector appId={appId} />}
|
||||
<AppUpgrades appId={appId} />
|
||||
</div>
|
||||
|
||||
{/* Rename Dialog */}
|
||||
|
||||
@@ -79,6 +79,8 @@ const validInvokeChannels = [
|
||||
"get-user-budget",
|
||||
"get-context-paths",
|
||||
"set-context-paths",
|
||||
"get-app-upgrades",
|
||||
"execute-app-upgrade",
|
||||
// Test-only channels
|
||||
// These should ALWAYS be guarded with IS_TEST_BUILD in the main process.
|
||||
// We can't detect with IS_TEST_BUILD in the preload script because
|
||||
|
||||
Reference in New Issue
Block a user