Auto-commit extra files (#197)

Whenever Dyad does a commit from a proposal, it will automatically amend
the commit with outside changes (e.g. made outside of Dyad).

This helps avoid a lot of user confusion, e.g.
https://github.com/dyad-sh/dyad/issues/187

https://www.reddit.com/r/dyadbuilders/comments/1kjysc0/error_pushing_images/

Edge cases:
If a user adds a file outside of Dyad, and then they hit retry, it will
revert these outside changes, but it's still technically in the version
history, so I think it's OK. This should also be a pretty unusual
situation.

Fixes #164 
Fixes #187
This commit is contained in:
Will Chen
2025-05-19 13:42:27 -07:00
committed by GitHub
parent 648724b4e9
commit b5671c0a59
8 changed files with 95 additions and 33 deletions

View File

@@ -58,7 +58,7 @@ import { useVersions } from "@/hooks/useVersions";
import { useAttachments } from "@/hooks/useAttachments"; import { useAttachments } from "@/hooks/useAttachments";
import { AttachmentsList } from "./AttachmentsList"; import { AttachmentsList } from "./AttachmentsList";
import { DragDropOverlay } from "./DragDropOverlay"; import { DragDropOverlay } from "./DragDropOverlay";
import { showError, showUncommittedFilesWarning } from "@/lib/toast"; import { showError, showExtraFilesToast } from "@/lib/toast";
import { ChatInputControls } from "../ChatInputControls"; import { ChatInputControls } from "../ChatInputControls";
const showTokenBarAtom = atom(false); const showTokenBarAtom = atom(false);
@@ -183,8 +183,11 @@ export function ChatInput({ chatId }: { chatId?: number }) {
chatId, chatId,
messageId, messageId,
}); });
if (result.uncommittedFiles) { if (result.extraFiles) {
showUncommittedFilesWarning(result.uncommittedFiles); showExtraFilesToast({
files: result.extraFiles,
error: result.extraFilesError,
});
} }
} catch (err) { } catch (err) {
console.error("Error approving proposal:", err); console.error("Error approving proposal:", err);

View File

@@ -14,7 +14,7 @@ import { useChats } from "./useChats";
import { useLoadApp } from "./useLoadApp"; import { useLoadApp } from "./useLoadApp";
import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useVersions } from "./useVersions"; import { useVersions } from "./useVersions";
import { showUncommittedFilesWarning } from "@/lib/toast"; import { showExtraFilesToast } from "@/lib/toast";
import { useProposal } from "./useProposal"; import { useProposal } from "./useProposal";
import { useSearch } from "@tanstack/react-router"; import { useSearch } from "@tanstack/react-router";
import { useRunApp } from "./useRunApp"; import { useRunApp } from "./useRunApp";
@@ -87,8 +87,11 @@ export function useStreamChat({
setIsPreviewOpen(true); setIsPreviewOpen(true);
refreshAppIframe(); refreshAppIframe();
} }
if (response.uncommittedFiles) { if (response.extraFiles) {
showUncommittedFilesWarning(response.uncommittedFiles); showExtraFilesToast({
files: response.extraFiles,
error: response.extraFilesError,
});
} }
refreshProposal(chatId); refreshProposal(chatId);

View File

@@ -535,7 +535,8 @@ This conversation includes one or more image attachments. When the user uploads
event.sender.send("chat:response:end", { event.sender.send("chat:response:end", {
chatId: req.chatId, chatId: req.chatId,
updatedFiles: status.updatedFiles ?? false, updatedFiles: status.updatedFiles ?? false,
uncommittedFiles: status.uncommittedFiles, extraFiles: status.extraFiles,
extraFilesError: status.extraFilesError,
} satisfies ChatResponseEnd); } satisfies ChatResponseEnd);
} else { } else {
event.sender.send("chat:response:end", { event.sender.send("chat:response:end", {

View File

@@ -30,6 +30,8 @@ import { extractCodebase } from "../../utils/codebase";
import { getDyadAppPath } from "../../paths/paths"; import { getDyadAppPath } from "../../paths/paths";
import { withLock } from "../utils/lock_utils"; import { withLock } from "../utils/lock_utils";
import { createLoggedHandler } from "./safe_handle"; import { createLoggedHandler } from "./safe_handle";
import { ApproveProposalResult } from "../ipc_types";
const logger = log.scope("proposal_handlers"); const logger = log.scope("proposal_handlers");
const handle = createLoggedHandler(logger); const handle = createLoggedHandler(logger);
// Cache for codebase token counts // Cache for codebase token counts
@@ -317,9 +319,7 @@ const getProposalHandler = async (
const approveProposalHandler = async ( const approveProposalHandler = async (
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
{ chatId, messageId }: { chatId: number; messageId: number }, { chatId, messageId }: { chatId: number; messageId: number },
): Promise<{ ): Promise<ApproveProposalResult> => {
uncommittedFiles?: string[];
}> => {
// 1. Fetch the specific assistant message // 1. Fetch the specific assistant message
const messageToApprove = await db.query.messages.findFirst({ const messageToApprove = await db.query.messages.findFirst({
where: and( where: and(
@@ -355,7 +355,10 @@ const approveProposalHandler = async (
); );
} }
return { uncommittedFiles: processResult.uncommittedFiles }; return {
extraFiles: processResult.extraFiles,
extraFilesError: processResult.extraFilesError,
};
}; };
// Handler to reject a proposal (just update message state) // Handler to reject a proposal (just update message state)

View File

@@ -26,6 +26,7 @@ import type {
CreateCustomLanguageModelProviderParams, CreateCustomLanguageModelProviderParams,
CreateCustomLanguageModelParams, CreateCustomLanguageModelParams,
DoesReleaseNoteExistParams, DoesReleaseNoteExistParams,
ApproveProposalResult,
} from "./ipc_types"; } from "./ipc_types";
import type { ProposalResult } from "@/lib/schemas"; import type { ProposalResult } from "@/lib/schemas";
import { showError } from "@/lib/toast"; import { showError } from "@/lib/toast";
@@ -601,9 +602,7 @@ export class IpcClient {
}: { }: {
chatId: number; chatId: number;
messageId: number; messageId: number;
}): Promise<{ }): Promise<ApproveProposalResult> {
uncommittedFiles?: string[];
}> {
return this.ipcRenderer.invoke("approve-proposal", { return this.ipcRenderer.invoke("approve-proposal", {
chatId, chatId,
messageId, messageId,

View File

@@ -24,7 +24,8 @@ export interface ChatStreamParams {
export interface ChatResponseEnd { export interface ChatResponseEnd {
chatId: number; chatId: number;
updatedFiles: boolean; updatedFiles: boolean;
uncommittedFiles?: string[]; extraFiles?: string[];
extraFilesError?: string;
} }
export interface CreateAppParams { export interface CreateAppParams {
@@ -185,3 +186,8 @@ export interface CreateCustomLanguageModelParams {
export interface DoesReleaseNoteExistParams { export interface DoesReleaseNoteExistParams {
version: string; version: string;
} }
export interface ApproveProposalResult {
extraFiles?: string[];
extraFilesError?: string;
}

View File

@@ -178,7 +178,8 @@ export async function processFullResponseActions(
): Promise<{ ): Promise<{
updatedFiles?: boolean; updatedFiles?: boolean;
error?: string; error?: string;
uncommittedFiles?: string[]; extraFiles?: string[];
extraFilesError?: string;
}> { }> {
logger.log("processFullResponseActions for chatId", chatId); logger.log("processFullResponseActions for chatId", chatId);
// Get the app associated with the chat // Get the app associated with the chat
@@ -413,6 +414,10 @@ export async function processFullResponseActions(
deletedFiles.length > 0 || deletedFiles.length > 0 ||
dyadAddDependencyPackages.length > 0 || dyadAddDependencyPackages.length > 0 ||
dyadExecuteSqlQueries.length > 0; dyadExecuteSqlQueries.length > 0;
let uncommittedFiles: string[] = [];
let extraFilesError: string | undefined;
if (hasChanges) { if (hasChanges) {
// Stage all written files // Stage all written files
for (const file of writtenFiles) { for (const file of writtenFiles) {
@@ -438,17 +443,54 @@ export async function processFullResponseActions(
if (dyadExecuteSqlQueries.length > 0) if (dyadExecuteSqlQueries.length > 0)
changes.push(`executed ${dyadExecuteSqlQueries.length} SQL queries`); changes.push(`executed ${dyadExecuteSqlQueries.length} SQL queries`);
let message = chatSummary
? `[dyad] ${chatSummary} - ${changes.join(", ")}`
: `[dyad] ${changes.join(", ")}`;
// Use chat summary, if provided, or default for commit message // Use chat summary, if provided, or default for commit message
const commitHash = await git.commit({ let commitHash = await git.commit({
fs, fs,
dir: appPath, dir: appPath,
message: chatSummary message,
? `[dyad] ${chatSummary} - ${changes.join(", ")}`
: `[dyad] ${changes.join(", ")}`,
author: await getGitAuthor(), author: await getGitAuthor(),
}); });
logger.log(`Successfully committed changes: ${changes.join(", ")}`); logger.log(`Successfully committed changes: ${changes.join(", ")}`);
// Check for any uncommitted changes after the commit
const statusMatrix = await git.statusMatrix({ fs, dir: appPath });
uncommittedFiles = statusMatrix
.filter((row) => row[1] !== 1 || row[2] !== 1 || row[3] !== 1)
.map((row) => row[0]); // Get just the file paths
if (uncommittedFiles.length > 0) {
// Stage all changes
await git.add({
fs,
dir: appPath,
filepath: ".",
});
try {
commitHash = await git.commit({
fs,
dir: appPath,
message: message + " + extra files edited outside of Dyad",
author: await getGitAuthor(),
amend: true,
});
logger.log(
`Amend commit with changes outside of dyad: ${uncommittedFiles.join(", ")}`,
);
} catch (error) {
// Just log, but don't throw an error because the user can still
// commit these changes outside of Dyad if needed.
logger.error(
`Failed to commit changes outside of dyad: ${uncommittedFiles.join(
", ",
)}`,
);
extraFilesError = (error as any).toString();
}
}
// Save the commit hash to the message // Save the commit hash to the message
await db await db
.update(messages) .update(messages)
@@ -457,13 +499,6 @@ export async function processFullResponseActions(
}) })
.where(eq(messages.id, messageId)); .where(eq(messages.id, messageId));
} }
// Check for any uncommitted changes after the commit
const statusMatrix = await git.statusMatrix({ fs, dir: appPath });
const uncommittedFiles = statusMatrix
.filter((row) => row[1] !== 1 || row[2] !== 1 || row[3] !== 1)
.map((row) => row[0]); // Get just the file paths
logger.log("mark as approved: hasChanges", hasChanges); logger.log("mark as approved: hasChanges", hasChanges);
// Update the message to approved // Update the message to approved
await db await db
@@ -475,8 +510,8 @@ export async function processFullResponseActions(
return { return {
updatedFiles: hasChanges, updatedFiles: hasChanges,
uncommittedFiles: extraFiles: uncommittedFiles.length > 0 ? uncommittedFiles : undefined,
uncommittedFiles.length > 0 ? uncommittedFiles : undefined, extraFilesError,
}; };
} catch (error: unknown) { } catch (error: unknown) {
logger.error("Error processing files:", error); logger.error("Error processing files:", error);

View File

@@ -58,11 +58,23 @@ export const showLoading = <T>(
}); });
}; };
export const showUncommittedFilesWarning = (files: string[]) => { export const showExtraFilesToast = ({
showWarning( files,
`Some changed files were not committed. Please use git to manually commit them. error,
}: {
files: string[];
error?: string;
}) => {
if (error) {
showError(
`Error committing files ${files.join(", ")} changed outside of Dyad: ${error}`,
);
} else {
showWarning(
`Files changed outside of Dyad have automatically been committed:
\n\n${files.join("\n")}`, \n\n${files.join("\n")}`,
); );
}
}; };
// Re-export for direct use // Re-export for direct use