From 55c0190f26026ad8e3ebff6fb28ce6bb552aab1f Mon Sep 17 00:00:00 2001 From: Will Chen Date: Wed, 23 Apr 2025 11:15:52 -0700 Subject: [PATCH] Support rename/delete edge function & standardize output --- src/components/chat/ChatInput.tsx | 31 +++- src/components/chat/DyadMarkdownParser.tsx | 13 ++ src/components/chat/DyadOutput.tsx | 77 ++++++++++ src/ipc/handlers/supabase_handlers.ts | 2 - src/ipc/processors/executeAddDependency.ts | 70 +++------ src/ipc/processors/response_processor.ts | 143 ++++++++++++++++-- .../supabase_management_client.ts | 26 ++++ 7 files changed, 295 insertions(+), 67 deletions(-) create mode 100644 src/components/chat/DyadOutput.tsx diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index da415c4..06eae15 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -330,12 +330,20 @@ function ChatInputActions({ }: ChatInputActionsProps) { const [autoApprove, setAutoApprove] = useState(false); const [isDetailsVisible, setIsDetailsVisible] = useState(false); + if (proposal.type === "tip-proposal") { return
Tip proposal
; } if (proposal.type === "action-proposal") { return ; } + + // Split files into server functions and other files - only for CodeProposal + const serverFunctions = + proposal.filesChanged?.filter((f: FileChange) => f.isServerFunction) ?? []; + const otherFilesChanged = + proposal.filesChanged?.filter((f: FileChange) => !f.isServerFunction) ?? []; + return (
@@ -462,11 +470,30 @@ function ChatInputActions({
)} - {proposal.filesChanged?.length > 0 && ( + {serverFunctions.length > 0 && ( +
+

Server Functions Changed

+
    + {serverFunctions.map((file: FileChange, index: number) => ( +
  • + {getIconForFileChange(file)} + + {file.name} + + + - {file.summary} + +
  • + ))} +
+
+ )} + + {otherFilesChanged.length > 0 && (

Files Changed

    - {proposal.filesChanged.map((file, index) => ( + {otherFilesChanged.map((file: FileChange, index: number) => (
  • {getIconForFileChange(file)} diff --git a/src/components/chat/DyadMarkdownParser.tsx b/src/components/chat/DyadMarkdownParser.tsx index e7b515e..598ac10 100644 --- a/src/components/chat/DyadMarkdownParser.tsx +++ b/src/components/chat/DyadMarkdownParser.tsx @@ -11,6 +11,7 @@ import { CodeHighlight } from "./CodeHighlight"; import { useAtomValue } from "jotai"; import { isStreamingAtom } from "@/atoms/chatAtoms"; import { CustomTagState } from "./stateTypes"; +import { DyadOutput } from "./DyadOutput"; interface DyadMarkdownParserProps { content: string; @@ -77,6 +78,7 @@ function preprocessUnclosedTags(content: string): { "dyad-add-dependency", "dyad-execute-sql", "dyad-add-integration", + "dyad-output", ]; let processedContent = content; @@ -137,6 +139,7 @@ function parseCustomTags(content: string): ContentPiece[] { "dyad-add-dependency", "dyad-execute-sql", "dyad-add-integration", + "dyad-output", ]; const tagPattern = new RegExp( @@ -303,6 +306,16 @@ function renderCustomTag( ); + case "dyad-output": + return ( + + {content} + + ); + default: return null; } diff --git a/src/components/chat/DyadOutput.tsx b/src/components/chat/DyadOutput.tsx new file mode 100644 index 0000000..8a78a2c --- /dev/null +++ b/src/components/chat/DyadOutput.tsx @@ -0,0 +1,77 @@ +import React, { useState } from "react"; +import { + ChevronsDownUp, + ChevronsUpDown, + AlertTriangle, + XCircle, +} from "lucide-react"; + +interface DyadOutputProps { + type: "error" | "warning"; + message?: string; + children?: React.ReactNode; +} + +export const DyadOutput: React.FC = ({ + type, + message, + children, +}) => { + const [isContentVisible, setIsContentVisible] = useState(false); + + // 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 ? ( + + ) : ( + + ); + const label = isError ? "Error" : "Warning"; + + return ( +
    setIsContentVisible(!isContentVisible)} + > + {/* Top-left label badge */} +
    + {icon} + {label} +
    + {/* Main content, padded to avoid label */} +
    +
    + {message && ( + + {message.slice(0, isContentVisible ? undefined : 80) + + (!isContentVisible ? "..." : "")} + + )} +
    +
    + {isContentVisible ? ( + + ) : ( + + )} +
    +
    + {isContentVisible && children && ( +
    + {children} +
    + )} +
    + ); +}; diff --git a/src/ipc/handlers/supabase_handlers.ts b/src/ipc/handlers/supabase_handlers.ts index cc7eea0..2a934aa 100644 --- a/src/ipc/handlers/supabase_handlers.ts +++ b/src/ipc/handlers/supabase_handlers.ts @@ -1,6 +1,4 @@ import { ipcMain } from "electron"; -import { SupabaseManagementAPI } from "supabase-management-js"; -import { readSettings, writeSettings } from "../../main/settings"; import log from "electron-log"; import { db } from "../../db"; import { eq } from "drizzle-orm"; diff --git a/src/ipc/processors/executeAddDependency.ts b/src/ipc/processors/executeAddDependency.ts index f7c079c..6580936 100644 --- a/src/ipc/processors/executeAddDependency.ts +++ b/src/ipc/processors/executeAddDependency.ts @@ -17,55 +17,31 @@ export async function executeAddDependency({ appPath: string; }) { const packageStr = packages.join(" "); - try { - const { stdout, stderr } = await execPromise( - `(pnpm add ${packageStr}) || (npm install ${packageStr})`, - { - cwd: appPath, - } - ); - const installResults = stdout + (stderr ? `\n${stderr}` : ""); - // Update the message content with the installation results - const updatedContent = message.content.replace( - new RegExp( - `[^<]*`, - "g" - ), + const { stdout, stderr } = await execPromise( + `(pnpm add ${packageStr}) || (npm install ${packageStr})`, + { + cwd: appPath, + } + ); + const installResults = stdout + (stderr ? `\n${stderr}` : ""); + + // Update the message content with the installation results + const updatedContent = message.content.replace( + new RegExp( `${installResults}` - ); + )}">[^<]*`, + "g" + ), + `${installResults}` + ); - // Save the updated message back to the database - await db - .update(messages) - .set({ content: updatedContent }) - .where(eq(messages.id, message.id)); - - // Return undefined implicitly - } catch (error: any) { - // Update the message with the error - const updatedContent = message.content.replace( - new RegExp( - `[^<]*`, - "g" - ), - `${ - error.message - }` - ); - - // Save the updated message back to the database - await db - .update(messages) - .set({ content: updatedContent }) - .where(eq(messages.id, message.id)); - - throw error; - } + // Save the updated message back to the database + await db + .update(messages) + .set({ content: updatedContent }) + .where(eq(messages.id, message.id)); } diff --git a/src/ipc/processors/response_processor.ts b/src/ipc/processors/response_processor.ts index d925bd0..9256b2c 100644 --- a/src/ipc/processors/response_processor.ts +++ b/src/ipc/processors/response_processor.ts @@ -10,11 +10,13 @@ import { getGitAuthor } from "../utils/git_author"; import log from "electron-log"; import { executeAddDependency } from "./executeAddDependency"; import { + deleteSupabaseFunction, deploySupabaseFunctions, executeSupabaseSql, } from "../../supabase_admin/supabase_management_client"; import { isServerFunction } from "../../supabase_admin/supabase_utils"; +const readFile = fs.promises.readFile; const logger = log.scope("response_processor"); export function getDyadWriteTags(fullResponse: string): { @@ -131,6 +133,23 @@ export function getDyadExecuteSqlTags(fullResponse: string): string[] { return queries; } +interface Output { + message: string; + error: unknown; +} + +function getFunctionNameFromPath(input: string): string { + return path.basename(path.extname(input) ? path.dirname(input) : input); +} + +async function readFileFromFunctionPath(input: string): Promise { + // Sometimes, the path given is a directory, sometimes it's the file itself. + if (path.extname(input) === "") { + return readFile(path.join(input, "index.ts"), "utf8"); + } + return readFile(input, "utf8"); +} + export async function processFullResponseActions( fullResponse: string, chatId: number, @@ -158,6 +177,9 @@ export async function processFullResponseActions( const deletedFiles: string[] = []; let hasChanges = false; + const warnings: Output[] = []; + const errors: Output[] = []; + try { // Extract all tags const dyadWriteTags = getDyadWriteTags(fullResponse); @@ -184,21 +206,37 @@ export async function processFullResponseActions( // Handle SQL execution tags if (dyadExecuteSqlQueries.length > 0) { for (const query of dyadExecuteSqlQueries) { - const result = await executeSupabaseSql({ - supabaseProjectId: chatWithApp.app.supabaseProjectId!, - query, - }); + try { + const result = await executeSupabaseSql({ + supabaseProjectId: chatWithApp.app.supabaseProjectId!, + query, + }); + } catch (error) { + errors.push({ + message: `Failed to execute SQL query: ${query}`, + error: error, + }); + } } logger.log(`Executed ${dyadExecuteSqlQueries.length} SQL queries`); } // TODO: Handle add dependency tags if (dyadAddDependencyPackages.length > 0) { - await executeAddDependency({ - packages: dyadAddDependencyPackages, - message: message, - appPath, - }); + try { + await executeAddDependency({ + packages: dyadAddDependencyPackages, + message: message, + appPath, + }); + } catch (error) { + errors.push({ + message: `Failed to add dependencies: ${dyadAddDependencyPackages.join( + ", " + )}`, + error: error, + }); + } writtenFiles.push("package.json"); const pnpmFilename = "pnpm-lock.yaml"; if (fs.existsSync(path.join(appPath, pnpmFilename))) { @@ -225,11 +263,18 @@ export async function processFullResponseActions( logger.log(`Successfully wrote file: ${fullFilePath}`); writtenFiles.push(filePath); if (isServerFunction(filePath)) { - await deploySupabaseFunctions({ - supabaseProjectId: chatWithApp.app.supabaseProjectId!, - functionName: path.basename(path.dirname(filePath)), - content: content, - }); + try { + await deploySupabaseFunctions({ + supabaseProjectId: chatWithApp.app.supabaseProjectId!, + functionName: path.basename(path.dirname(filePath)), + content: content, + }); + } catch (error) { + errors.push({ + message: `Failed to deploy Supabase function: ${filePath}`, + error: error, + }); + } } } @@ -267,6 +312,33 @@ export async function processFullResponseActions( } else { logger.warn(`Source file for rename does not exist: ${fromPath}`); } + if (isServerFunction(tag.from)) { + try { + await deleteSupabaseFunction({ + supabaseProjectId: chatWithApp.app.supabaseProjectId!, + functionName: getFunctionNameFromPath(tag.from), + }); + } catch (error) { + warnings.push({ + message: `Failed to delete Supabase function: ${tag.from} as part of renaming ${tag.from} to ${tag.to}`, + error: error, + }); + } + } + if (isServerFunction(tag.to)) { + try { + await deploySupabaseFunctions({ + supabaseProjectId: chatWithApp.app.supabaseProjectId!, + functionName: getFunctionNameFromPath(tag.to), + content: await readFileFromFunctionPath(toPath), + }); + } catch (error) { + errors.push({ + message: `Failed to deploy Supabase function: ${tag.to} as part of renaming ${tag.from} to ${tag.to}`, + error: error, + }); + } + } } // Process all file deletions @@ -275,7 +347,11 @@ export async function processFullResponseActions( // Delete the file if it exists if (fs.existsSync(fullFilePath)) { - fs.unlinkSync(fullFilePath); + if (fs.lstatSync(fullFilePath).isDirectory()) { + fs.rmdirSync(fullFilePath, { recursive: true }); + } else { + fs.unlinkSync(fullFilePath); + } logger.log(`Successfully deleted file: ${fullFilePath}`); deletedFiles.push(filePath); @@ -293,6 +369,19 @@ export async function processFullResponseActions( } else { logger.warn(`File to delete does not exist: ${fullFilePath}`); } + if (isServerFunction(filePath)) { + try { + await deleteSupabaseFunction({ + supabaseProjectId: chatWithApp.app.supabaseProjectId!, + functionName: getFunctionNameFromPath(filePath), + }); + } catch (error) { + errors.push({ + message: `Failed to delete Supabase function: ${filePath}`, + error: error, + }); + } + } } // If we have any file changes, commit them all at once @@ -346,10 +435,32 @@ export async function processFullResponseActions( approvalState: "approved", }) .where(eq(messages.id, messageId)); - return { updatedFiles: hasChanges }; } catch (error: unknown) { logger.error("Error processing files:", error); return { error: (error as any).toString() }; + } finally { + const appendedContent = ` + ${warnings + .map( + (warning) => + `${warning.error}` + ) + .join("\n")} + ${errors + .map( + (error) => + `${error.error}` + ) + .join("\n")} + `; + if (appendedContent.length > 0) { + await db + .update(messages) + .set({ + content: fullResponse + "\n\n" + appendedContent, + }) + .where(eq(messages.id, messageId)); + } } } diff --git a/src/supabase_admin/supabase_management_client.ts b/src/supabase_admin/supabase_management_client.ts index 81a79b1..78f01b8 100644 --- a/src/supabase_admin/supabase_management_client.ts +++ b/src/supabase_admin/supabase_management_client.ts @@ -4,6 +4,9 @@ import { SupabaseManagementAPI, SupabaseManagementAPIError, } from "@dyad-sh/supabase-management-js"; +import log from "electron-log"; + +const logger = log.scope("supabase_management_client"); /** * Checks if the Supabase access token is expired or about to expire @@ -137,6 +140,23 @@ export async function executeSupabaseSql({ return JSON.stringify(result); } +export async function deleteSupabaseFunction({ + supabaseProjectId, + functionName, +}: { + supabaseProjectId: string; + functionName: string; +}): Promise { + logger.info( + `Deleting Supabase function: ${functionName} from project: ${supabaseProjectId}` + ); + const supabase = await getSupabaseClient(); + await supabase.deleteFunction(supabaseProjectId, functionName); + logger.info( + `Deleted Supabase function: ${functionName} from project: ${supabaseProjectId}` + ); +} + export async function deploySupabaseFunctions({ supabaseProjectId, functionName, @@ -146,6 +166,9 @@ export async function deploySupabaseFunctions({ functionName: string; content: string; }): Promise { + logger.info( + `Deploying Supabase function: ${functionName} to project: ${supabaseProjectId}` + ); const supabase = await getSupabaseClient(); const formData = new FormData(); formData.append( @@ -172,6 +195,9 @@ export async function deploySupabaseFunctions({ throw await createResponseError(response, "create function"); } + logger.info( + `Deployed Supabase function: ${functionName} to project: ${supabaseProjectId}` + ); return response.json(); }