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

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