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:
Will Chen
2025-06-11 13:05:27 -07:00
committed by GitHub
parent b86738f3ab
commit c1aa6803ce
79 changed files with 12896 additions and 113 deletions

View File

@@ -0,0 +1,6 @@
import { ComponentSelection } from "@/ipc/ipc_types";
import { atom } from "jotai";
export const selectedComponentPreviewAtom = atom<ComponentSelection | null>(
null,
);

View 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>
);
}

View File

@@ -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"
>

View File

@@ -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}

View File

@@ -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>
);

View 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>
);
}

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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;

View 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}`);
}
},
);
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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 */}

View File

@@ -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