Problems: auto-fix & problem panel (#541)

Test cases:
- [x] create-ts-errors
  - [x] with auto-fix
  - [x] without auto-fix 
- [x] create-unfixable-ts-errors
- [x] manually edit file & click recheck
- [x] fix all
- [x] delete and rename case

THINGS
- [x] error handling for checkProblems isn't working as expected
- [x] make sure it works for both default templates (add tests) 
- [x] fix bad animation
- [x] change file context (prompt/files)

IF everything passes in Windows AND defensive try catch... then enable
by default
- [x] enable auto-fix by default
This commit is contained in:
Will Chen
2025-07-02 15:43:26 -07:00
committed by GitHub
parent 52205be9db
commit 678cd3277e
65 changed files with 5068 additions and 189 deletions

View File

@@ -0,0 +1,63 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`problem_prompt > createConciseProblemFixPrompt > should format a concise prompt for multiple errors 1`] = `
"Fix these 2 TypeScript compile-time errors:
1. src/main.ts:5:12 - Cannot find module 'react-dom/client' or its corresponding type declarations. (TS2307)
2. src/components/Modal.tsx:35:20 - Property 'isOpen' does not exist on type 'IntrinsicAttributes & ModalProps'. (TS2339)
Please fix all errors in a concise way."
`;
exports[`problem_prompt > createConciseProblemFixPrompt > should format a concise prompt for single error 1`] = `
"Fix these 1 TypeScript compile-time error:
1. src/App.tsx:10:5 - Cannot find name 'consol'. Did you mean 'console'? (TS2552)
Please fix all errors in a concise way."
`;
exports[`problem_prompt > createConciseProblemFixPrompt > should return a short message when no problems exist 1`] = `"No TypeScript problems detected."`;
exports[`problem_prompt > createProblemFixPrompt > should format a single error correctly 1`] = `
"Fix these 1 TypeScript compile-time error:
1. src/components/Button.tsx:15:23 - Property 'onClick' does not exist on type 'ButtonProps'. (TS2339)
Please fix all errors in a concise way."
`;
exports[`problem_prompt > createProblemFixPrompt > should format multiple errors across multiple files 1`] = `
"Fix these 4 TypeScript compile-time errors:
1. src/components/Button.tsx:15:23 - Property 'onClick' does not exist on type 'ButtonProps'. (TS2339)
2. src/components/Button.tsx:8:12 - Type 'string | undefined' is not assignable to type 'string'. (TS2322)
3. src/hooks/useApi.ts:42:5 - Argument of type 'unknown' is not assignable to parameter of type 'string'. (TS2345)
4. src/utils/helpers.ts:45:8 - Function lacks ending return statement and return type does not include 'undefined'. (TS2366)
Please fix all errors in a concise way."
`;
exports[`problem_prompt > createProblemFixPrompt > should handle realistic React TypeScript errors 1`] = `
"Fix these 4 TypeScript compile-time errors:
1. src/components/UserProfile.tsx:12:35 - Type '{ children: string; }' is missing the following properties from type 'UserProfileProps': user, onEdit (TS2739)
2. src/components/UserProfile.tsx:25:15 - Object is possibly 'null'. (TS2531)
3. src/hooks/useLocalStorage.ts:18:12 - Type 'string | null' is not assignable to type 'T'. (TS2322)
4. src/types/api.ts:45:3 - Duplicate identifier 'UserRole'. (TS2300)
Please fix all errors in a concise way."
`;
exports[`problem_prompt > createProblemFixPrompt > should return a message when no problems exist 1`] = `"No TypeScript problems detected."`;
exports[`problem_prompt > realistic TypeScript error scenarios > should handle common React + TypeScript errors 1`] = `
"Fix these 4 TypeScript compile-time errors:
1. src/components/ProductCard.tsx:22:18 - Property 'price' is missing in type '{ name: string; description: string; }' but required in type 'Product'. (TS2741)
2. src/components/SearchInput.tsx:15:45 - Type '(value: string) => void' is not assignable to type 'ChangeEventHandler<HTMLInputElement>'. (TS2322)
3. src/api/userService.ts:8:1 - Function lacks ending return statement and return type does not include 'undefined'. (TS2366)
4. src/utils/dataProcessor.ts:34:25 - Object is possibly 'undefined'. (TS2532)
Please fix all errors in a concise way."
`;

View File

@@ -0,0 +1,214 @@
import { describe, it, expect } from "vitest";
import { createProblemFixPrompt } from "../shared/problem_prompt";
import type { ProblemReport } from "../ipc/ipc_types";
describe("problem_prompt", () => {
describe("createProblemFixPrompt", () => {
it("should return a message when no problems exist", () => {
const problemReport: ProblemReport = {
problems: [],
};
const result = createProblemFixPrompt(problemReport);
expect(result).toMatchSnapshot();
});
it("should format a single error correctly", () => {
const problemReport: ProblemReport = {
problems: [
{
file: "src/components/Button.tsx",
line: 15,
column: 23,
message: "Property 'onClick' does not exist on type 'ButtonProps'.",
code: 2339,
},
],
};
const result = createProblemFixPrompt(problemReport);
expect(result).toMatchSnapshot();
});
it("should format multiple errors across multiple files", () => {
const problemReport: ProblemReport = {
problems: [
{
file: "src/components/Button.tsx",
line: 15,
column: 23,
message: "Property 'onClick' does not exist on type 'ButtonProps'.",
code: 2339,
},
{
file: "src/components/Button.tsx",
line: 8,
column: 12,
message:
"Type 'string | undefined' is not assignable to type 'string'.",
code: 2322,
},
{
file: "src/hooks/useApi.ts",
line: 42,
column: 5,
message:
"Argument of type 'unknown' is not assignable to parameter of type 'string'.",
code: 2345,
},
{
file: "src/utils/helpers.ts",
line: 45,
column: 8,
message:
"Function lacks ending return statement and return type does not include 'undefined'.",
code: 2366,
},
],
};
const result = createProblemFixPrompt(problemReport);
expect(result).toMatchSnapshot();
});
it("should handle realistic React TypeScript errors", () => {
const problemReport: ProblemReport = {
problems: [
{
file: "src/components/UserProfile.tsx",
line: 12,
column: 35,
message:
"Type '{ children: string; }' is missing the following properties from type 'UserProfileProps': user, onEdit",
code: 2739,
},
{
file: "src/components/UserProfile.tsx",
line: 25,
column: 15,
message: "Object is possibly 'null'.",
code: 2531,
},
{
file: "src/hooks/useLocalStorage.ts",
line: 18,
column: 12,
message: "Type 'string | null' is not assignable to type 'T'.",
code: 2322,
},
{
file: "src/types/api.ts",
line: 45,
column: 3,
message: "Duplicate identifier 'UserRole'.",
code: 2300,
},
],
};
const result = createProblemFixPrompt(problemReport);
expect(result).toMatchSnapshot();
});
});
describe("createConciseProblemFixPrompt", () => {
it("should return a short message when no problems exist", () => {
const problemReport: ProblemReport = {
problems: [],
};
const result = createProblemFixPrompt(problemReport);
expect(result).toMatchSnapshot();
});
it("should format a concise prompt for single error", () => {
const problemReport: ProblemReport = {
problems: [
{
file: "src/App.tsx",
line: 10,
column: 5,
message: "Cannot find name 'consol'. Did you mean 'console'?",
code: 2552,
},
],
};
const result = createProblemFixPrompt(problemReport);
expect(result).toMatchSnapshot();
});
it("should format a concise prompt for multiple errors", () => {
const problemReport: ProblemReport = {
problems: [
{
file: "src/main.ts",
line: 5,
column: 12,
message:
"Cannot find module 'react-dom/client' or its corresponding type declarations.",
code: 2307,
},
{
file: "src/components/Modal.tsx",
line: 35,
column: 20,
message:
"Property 'isOpen' does not exist on type 'IntrinsicAttributes & ModalProps'.",
code: 2339,
},
],
};
const result = createProblemFixPrompt(problemReport);
expect(result).toMatchSnapshot();
});
});
describe("realistic TypeScript error scenarios", () => {
it("should handle common React + TypeScript errors", () => {
const problemReport: ProblemReport = {
problems: [
// Missing interface property
{
file: "src/components/ProductCard.tsx",
line: 22,
column: 18,
message:
"Property 'price' is missing in type '{ name: string; description: string; }' but required in type 'Product'.",
code: 2741,
},
// Incorrect event handler type
{
file: "src/components/SearchInput.tsx",
line: 15,
column: 45,
message:
"Type '(value: string) => void' is not assignable to type 'ChangeEventHandler<HTMLInputElement>'.",
code: 2322,
},
// Async/await without Promise return type
{
file: "src/api/userService.ts",
line: 8,
column: 1,
message:
"Function lacks ending return statement and return type does not include 'undefined'.",
code: 2366,
},
// Strict null check
{
file: "src/utils/dataProcessor.ts",
line: 34,
column: 25,
message: "Object is possibly 'undefined'.",
code: 2532,
},
],
};
const result = createProblemFixPrompt(problemReport);
expect(result).toMatchSnapshot();
});
});
});

View File

@@ -7,7 +7,7 @@ export const selectedAppIdAtom = atom<number | null>(null);
export const appsListAtom = atom<App[]>([]);
export const appBasePathAtom = atom<string>("");
export const versionsListAtom = atom<Version[]>([]);
export const previewModeAtom = atom<"preview" | "code">("preview");
export const previewModeAtom = atom<"preview" | "code" | "problems">("preview");
export const selectedVersionIdAtom = atom<string | null>(null);
export const appOutputAtom = atom<AppOutput[]>([]);
export const appUrlAtom = atom<

View File

@@ -0,0 +1,21 @@
import { useSettings } from "@/hooks/useSettings";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
export function AutoFixProblemsSwitch() {
const { settings, updateSettings } = useSettings();
return (
<div className="flex items-center space-x-2">
<Switch
id="auto-fix-problems"
checked={settings?.enableAutoFixProblems}
onCheckedChange={() => {
updateSettings({
enableAutoFixProblems: !settings?.enableAutoFixProblems,
});
}}
/>
<Label htmlFor="auto-fix-problems">Auto-fix problems</Label>
</div>
);
}

View File

@@ -63,6 +63,7 @@ import { ChatInputControls } from "../ChatInputControls";
import { ChatErrorBox } from "./ChatErrorBox";
import { selectedComponentPreviewAtom } from "@/atoms/previewAtoms";
import { SelectedComponentDisplay } from "./SelectedComponentDisplay";
import { useCheckProblems } from "@/hooks/useCheckProblems";
const showTokenBarAtom = atom(false);
@@ -84,7 +85,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const [selectedComponent, setSelectedComponent] = useAtom(
selectedComponentPreviewAtom,
);
const { checkProblems } = useCheckProblems(appId);
// Use the attachments hook
const {
attachments,
@@ -207,6 +208,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
setIsApproving(false);
setIsPreviewOpen(true);
refreshVersions();
checkProblems();
// Keep same as handleReject
refreshProposal();

View File

@@ -15,6 +15,7 @@ import { useAtomValue } from "jotai";
import { isStreamingAtom } from "@/atoms/chatAtoms";
import { CustomTagState } from "./stateTypes";
import { DyadOutput } from "./DyadOutput";
import { DyadProblemSummary } from "./DyadProblemSummary";
import { IpcClient } from "@/ipc/ipc_client";
interface DyadMarkdownParserProps {
@@ -117,6 +118,7 @@ function preprocessUnclosedTags(content: string): {
"dyad-execute-sql",
"dyad-add-integration",
"dyad-output",
"dyad-problem-report",
"dyad-chat-summary",
"dyad-edit",
"dyad-codebase-context",
@@ -182,6 +184,7 @@ function parseCustomTags(content: string): ContentPiece[] {
"dyad-execute-sql",
"dyad-add-integration",
"dyad-output",
"dyad-problem-report",
"dyad-chat-summary",
"dyad-edit",
"dyad-codebase-context",
@@ -404,6 +407,13 @@ function renderCustomTag(
</DyadOutput>
);
case "dyad-problem-report":
return (
<DyadProblemSummary summary={attributes.summary}>
{content}
</DyadProblemSummary>
);
case "dyad-chat-summary":
// Don't render anything for dyad-chat-summary
return null;

View File

@@ -0,0 +1,154 @@
import React, { useState } from "react";
import {
ChevronsDownUp,
ChevronsUpDown,
AlertTriangle,
FileText,
} from "lucide-react";
import type { Problem } from "@/ipc/ipc_types";
interface DyadProblemSummaryProps {
summary?: string;
children?: React.ReactNode;
}
interface ProblemItemProps {
problem: Problem;
index: number;
}
const ProblemItem: React.FC<ProblemItemProps> = ({ problem, index }) => {
return (
<div className="flex items-start gap-3 py-2 px-3 border-b border-gray-200 dark:border-gray-700 last:border-b-0">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mt-0.5">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
{index + 1}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<FileText size={14} className="text-gray-500 flex-shrink-0" />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{problem.file}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{problem.line}:{problem.column}
</span>
<span className="text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded text-gray-600 dark:text-gray-300">
TS{problem.code}
</span>
</div>
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
{problem.message}
</p>
</div>
</div>
);
};
export const DyadProblemSummary: React.FC<DyadProblemSummaryProps> = ({
summary,
children,
}) => {
const [isContentVisible, setIsContentVisible] = useState(false);
// Parse problems from children content if available
const problems: Problem[] = React.useMemo(() => {
if (!children || typeof children !== "string") return [];
// Parse structured format with <problem> tags
const problemTagRegex =
/<problem\s+file="([^"]+)"\s+line="(\d+)"\s+column="(\d+)"\s+code="(\d+)">([^<]+)<\/problem>/g;
const problems: Problem[] = [];
let match;
while ((match = problemTagRegex.exec(children)) !== null) {
try {
problems.push({
file: match[1],
line: parseInt(match[2], 10),
column: parseInt(match[3], 10),
message: match[5].trim(),
code: parseInt(match[4], 10),
});
} catch {
return [
{
file: "unknown",
line: 0,
column: 0,
message: children,
code: 0,
},
];
}
}
return problems;
}, [children]);
const totalProblems = problems.length;
const displaySummary =
summary || `${totalProblems} problems found (TypeScript errors)`;
return (
<div
className="bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border border-border my-2 cursor-pointer"
onClick={() => setIsContentVisible(!isContentVisible)}
data-testid="problem-summary"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertTriangle
size={16}
className="text-amber-600 dark:text-amber-500"
/>
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
<span className="font-bold mr-2 outline-2 outline-amber-200 dark:outline-amber-700 bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-200 rounded-md px-1">
Auto-fix
</span>
{displaySummary}
</span>
</div>
<div className="flex items-center">
{isContentVisible ? (
<ChevronsDownUp
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
) : (
<ChevronsUpDown
size={20}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
/>
)}
</div>
</div>
{/* Content area - show individual problems */}
{isContentVisible && totalProblems > 0 && (
<div className="mt-4">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
{problems.map((problem, index) => (
<ProblemItem
key={`${problem.file}-${problem.line}-${problem.column}-${index}`}
problem={problem}
index={index}
/>
))}
</div>
</div>
)}
{/* Fallback content area for raw children */}
{isContentVisible && totalProblems === 0 && children && (
<div className="mt-4 text-sm text-gray-800 dark:text-gray-200">
<pre className="whitespace-pre-wrap font-mono text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded">
{children}
</pre>
</div>
)}
</div>
);
};

View File

@@ -9,6 +9,7 @@ import { IpcClient } from "@/ipc/ipc_client";
import { CodeView } from "./CodeView";
import { PreviewIframe } from "./PreviewIframe";
import { Problems } from "./Problems";
import {
Eye,
Code,
@@ -19,6 +20,7 @@ import {
Cog,
Power,
Trash2,
AlertTriangle,
} from "lucide-react";
import { motion } from "framer-motion";
import { useEffect, useRef, useState, useCallback } from "react";
@@ -33,8 +35,9 @@ import {
} from "@/components/ui/dropdown-menu";
import { showError, showSuccess } from "@/lib/toast";
import { useMutation } from "@tanstack/react-query";
import { useCheckProblems } from "@/hooks/useCheckProblems";
type PreviewMode = "preview" | "code";
type PreviewMode = "preview" | "code" | "problems";
interface PreviewHeaderProps {
previewMode: PreviewMode;
@@ -57,81 +60,156 @@ const PreviewHeader = ({
onRestart,
onCleanRestart,
onClearSessionData,
}: PreviewHeaderProps) => (
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
<div className="relative flex space-x-2 bg-[var(--background-darkest)] rounded-md p-0.5">
<button
className="relative flex items-center space-x-1 px-3 py-1 rounded-md text-sm z-10"
onClick={() => setPreviewMode("preview")}
>
{previewMode === "preview" && (
<motion.div
layoutId="activeIndicator"
className="absolute inset-0 bg-(--background-lightest) shadow rounded-md -z-1"
transition={{ type: "spring", stiffness: 500, damping: 35 }}
/>
)}
<Eye size={16} />
<span>Preview</span>
</button>
<button
className="relative flex items-center space-x-1 px-3 py-1 rounded-md text-sm z-10"
onClick={() => setPreviewMode("code")}
>
{previewMode === "code" && (
<motion.div
layoutId="activeIndicator"
className="absolute inset-0 bg-(--background-lightest) shadow rounded-md -z-1"
transition={{ type: "spring", stiffness: 500, damping: 35 }}
/>
)}
<Code size={16} />
<span>Code</span>
</button>
}: PreviewHeaderProps) => {
const selectedAppId = useAtomValue(selectedAppIdAtom);
const previewRef = useRef<HTMLButtonElement>(null);
const codeRef = useRef<HTMLButtonElement>(null);
const problemsRef = useRef<HTMLButtonElement>(null);
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });
const { problemReport } = useCheckProblems(selectedAppId);
// Get the problem count for the selected app
const problemCount = problemReport ? problemReport.problems.length : 0;
// Format the problem count for display
const formatProblemCount = (count: number): string => {
if (count === 0) return "";
if (count > 100) return "100+";
return count.toString();
};
const displayCount = formatProblemCount(problemCount);
// Update indicator position when mode changes
useEffect(() => {
const updateIndicator = () => {
let targetRef: React.RefObject<HTMLButtonElement | null>;
switch (previewMode) {
case "preview":
targetRef = previewRef;
break;
case "code":
targetRef = codeRef;
break;
case "problems":
targetRef = problemsRef;
break;
default:
return;
}
if (targetRef.current) {
const button = targetRef.current;
const container = button.parentElement;
if (container) {
const containerRect = container.getBoundingClientRect();
const buttonRect = button.getBoundingClientRect();
const left = buttonRect.left - containerRect.left;
const width = buttonRect.width;
setIndicatorStyle({ left, width });
}
}
};
// Small delay to ensure DOM is updated
const timeoutId = setTimeout(updateIndicator, 10);
return () => clearTimeout(timeoutId);
}, [previewMode, displayCount]);
return (
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
<div className="relative flex bg-[var(--background-darkest)] rounded-md p-0.5">
<motion.div
className="absolute top-0.5 bottom-0.5 bg-[var(--background-lightest)] shadow rounded-md"
animate={{
left: indicatorStyle.left,
width: indicatorStyle.width,
}}
transition={{
type: "spring",
stiffness: 600,
damping: 35,
mass: 0.6,
}}
/>
<button
data-testid="preview-mode-button"
ref={previewRef}
className="relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10"
onClick={() => setPreviewMode("preview")}
>
<Eye size={14} />
<span>Preview</span>
</button>
<button
data-testid="problems-mode-button"
ref={problemsRef}
className="relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10"
onClick={() => setPreviewMode("problems")}
>
<AlertTriangle size={14} />
<span>Problems</span>
{displayCount && (
<span className="ml-0.5 px-1 py-0.5 text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-full min-w-[16px] text-center">
{displayCount}
</span>
)}
</button>
<button
data-testid="code-mode-button"
ref={codeRef}
className="relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10"
onClick={() => setPreviewMode("code")}
>
<Code size={14} />
<span>Code</span>
</button>
</div>
<div className="flex items-center">
<button
onClick={onRestart}
className="flex items-center space-x-1 px-3 py-1 rounded-md text-sm hover:bg-[var(--background-darkest)] transition-colors"
title="Restart App"
>
<Power size={16} />
<span>Restart</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
data-testid="preview-more-options-button"
className="flex items-center justify-center p-1.5 rounded-md text-sm hover:bg-[var(--background-darkest)] transition-colors"
title="More options"
>
<MoreVertical size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuItem onClick={onCleanRestart}>
<Cog size={16} />
<div className="flex flex-col">
<span>Rebuild</span>
<span className="text-xs text-muted-foreground">
Re-installs node_modules and restarts
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={onClearSessionData}>
<Trash2 size={16} />
<div className="flex flex-col">
<span>Clear Preview Data</span>
<span className="text-xs text-muted-foreground">
Clears cookies and local storage for the app preview
</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex items-center">
<button
onClick={onRestart}
className="flex items-center space-x-1 px-3 py-1 rounded-md text-sm hover:bg-[var(--background-darkest)] transition-colors"
title="Restart App"
>
<Power size={16} />
<span>Restart</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
data-testid="preview-more-options-button"
className="flex items-center justify-center p-1.5 rounded-md text-sm hover:bg-[var(--background-darkest)] transition-colors"
title="More options"
>
<MoreVertical size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuItem onClick={onCleanRestart}>
<Cog size={16} />
<div className="flex flex-col">
<span>Rebuild</span>
<span className="text-xs text-muted-foreground">
Re-installs node_modules and restarts
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={onClearSessionData}>
<Trash2 size={16} />
<div className="flex flex-col">
<span>Clear Preview Data</span>
<span className="text-xs text-muted-foreground">
Clears cookies and local storage for the app preview
</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
);
};
// Console header component
const ConsoleHeader = ({
@@ -262,8 +340,10 @@ export function PreviewPanel() {
<div className="h-full overflow-y-auto">
{previewMode === "preview" ? (
<PreviewIframe key={key} loading={loading} />
) : (
) : previewMode === "code" ? (
<CodeView loading={loading} app={app} />
) : (
<Problems />
)}
</div>
</Panel>

View File

@@ -0,0 +1,208 @@
import { useAtom, useAtomValue } from "jotai";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import {
AlertTriangle,
XCircle,
FileText,
Wrench,
RefreshCw,
} from "lucide-react";
import { Problem, ProblemReport } from "@/ipc/ipc_types";
import { Button } from "@/components/ui/button";
import { useStreamChat } from "@/hooks/useStreamChat";
import { useCheckProblems } from "@/hooks/useCheckProblems";
import { createProblemFixPrompt } from "@/shared/problem_prompt";
import { showError } from "@/lib/toast";
interface ProblemItemProps {
problem: Problem;
}
const ProblemItem = ({ problem }: ProblemItemProps) => {
return (
<div className="flex items-start gap-3 p-3 border-b border-border hover:bg-[var(--background-darkest)] transition-colors">
<div className="flex-shrink-0 mt-0.5">
<XCircle size={16} className="text-red-500" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<FileText size={14} className="text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium truncate">{problem.file}</span>
<span className="text-xs text-muted-foreground">
{problem.line}:{problem.column}
</span>
</div>
<p className="text-sm text-foreground leading-relaxed">
{problem.message}
</p>
</div>
</div>
);
};
interface RecheckButtonProps {
appId: number;
size?: "sm" | "default" | "lg";
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
className?: string;
}
const RecheckButton = ({
appId,
size = "sm",
variant = "outline",
className = "h-7 px-3 text-xs",
}: RecheckButtonProps) => {
const { checkProblems, isChecking } = useCheckProblems(appId);
const handleRecheck = async () => {
const res = await checkProblems();
if (res.error) {
showError(res.error);
}
};
return (
<Button
size={size}
variant={variant}
onClick={handleRecheck}
disabled={isChecking}
className={className}
data-testid="recheck-button"
>
<RefreshCw
size={14}
className={`mr-1 ${isChecking ? "animate-spin" : ""}`}
/>
{isChecking ? "Checking..." : "Recheck"}
</Button>
);
};
interface ProblemsSummaryProps {
problemReport: ProblemReport;
appId: number;
}
const ProblemsSummary = ({ problemReport, appId }: ProblemsSummaryProps) => {
const { streamMessage } = useStreamChat();
const { problems } = problemReport;
const totalErrors = problems.length;
const [selectedChatId] = useAtom(selectedChatIdAtom);
const handleFixAll = () => {
if (!selectedChatId) {
return;
}
streamMessage({
prompt: createProblemFixPrompt(problemReport),
chatId: selectedChatId,
});
};
if (problems.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-32 text-center">
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center mb-3">
<div className="w-6 h-6 rounded-full bg-green-500"></div>
</div>
<p className="text-sm text-muted-foreground mb-3">No problems found</p>
<RecheckButton appId={appId} />
</div>
);
}
return (
<div className="flex items-center justify-between px-4 py-3 bg-[var(--background-darkest)] border-b border-border">
<div className="flex items-center gap-4">
{totalErrors > 0 && (
<div className="flex items-center gap-2">
<XCircle size={16} className="text-red-500" />
<span className="text-sm font-medium">
{totalErrors} {totalErrors === 1 ? "error" : "errors"}
</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
<RecheckButton appId={appId} />
<Button
size="sm"
variant="default"
onClick={handleFixAll}
className="h-7 px-3 text-xs"
data-testid="fix-all-button"
>
<Wrench size={14} className="mr-1" />
Fix All
</Button>
</div>
</div>
);
};
export function Problems() {
return (
<div data-testid="problems-pane">
<_Problems />
</div>
);
}
export function _Problems() {
const selectedAppId = useAtomValue(selectedAppIdAtom);
const { problemReport } = useCheckProblems(selectedAppId);
if (!selectedAppId) {
return (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<div className="w-16 h-16 rounded-full bg-[var(--background-darkest)] flex items-center justify-center mb-4">
<AlertTriangle size={24} className="text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">No App Selected</h3>
<p className="text-sm text-muted-foreground max-w-md">
Select an app to view TypeScript problems and diagnostic information.
</p>
</div>
);
}
if (!problemReport) {
return (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<div className="w-16 h-16 rounded-full bg-[var(--background-darkest)] flex items-center justify-center mb-4">
<FileText size={24} className="text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">No Problems Data</h3>
<p className="text-sm text-muted-foreground max-w-md mb-4">
No TypeScript diagnostics available for this app yet. Problems will
appear here after running type checking.
</p>
<RecheckButton appId={selectedAppId} />
</div>
);
}
return (
<div className="flex flex-col h-full">
<ProblemsSummary problemReport={problemReport} appId={selectedAppId} />
<div className="flex-1 overflow-y-auto">
{problemReport.problems.map((problem, index) => (
<ProblemItem
key={`${problem.file}-${problem.line}-${problem.column}-${index}`}
problem={problem}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { useQuery } from "@tanstack/react-query";
import { IpcClient } from "@/ipc/ipc_client";
import type { ProblemReport } from "@/ipc/ipc_types";
export function useCheckProblems(appId: number | null) {
const {
data: problemReport,
isLoading: isChecking,
error,
refetch: checkProblems,
} = useQuery<ProblemReport, Error>({
queryKey: ["problems", appId],
queryFn: async (): Promise<ProblemReport> => {
if (!appId) {
throw new Error("App ID is required");
}
const ipcClient = IpcClient.getInstance();
return ipcClient.checkProblems({ appId });
},
enabled: !!appId,
// DO NOT SHOW ERROR TOAST.
});
return {
problemReport,
isChecking,
error,
checkProblems,
};
}

View File

@@ -21,6 +21,7 @@ import { useRunApp } from "./useRunApp";
import { useCountTokens } from "./useCountTokens";
import { useUserBudgetInfo } from "./useUserBudgetInfo";
import { usePostHog } from "posthog-js/react";
import { useCheckProblems } from "./useCheckProblems";
export function getRandomNumberId() {
return Math.floor(Math.random() * 1_000_000_000_000_000);
@@ -41,6 +42,7 @@ export function useStreamChat({
const { refreshAppIframe } = useRunApp();
const { countTokens } = useCountTokens();
const { refetchUserBudget } = useUserBudgetInfo();
const { checkProblems } = useCheckProblems(selectedAppId);
const posthog = usePostHog();
let chatId: number | undefined;
@@ -73,6 +75,7 @@ export function useStreamChat({
setError(null);
setIsStreaming(true);
let hasIncrementedStreamCount = false;
try {
IpcClient.getInstance().streamMessage(prompt, {
@@ -92,6 +95,7 @@ export function useStreamChat({
if (response.updatedFiles) {
setIsPreviewOpen(true);
refreshAppIframe();
checkProblems();
}
if (response.extraFiles) {
showExtraFilesToast({
@@ -129,7 +133,14 @@ export function useStreamChat({
setError(error instanceof Error ? error.message : String(error));
}
},
[setMessages, setIsStreaming, setIsPreviewOpen, refetchUserBudget],
[
setMessages,
setIsStreaming,
setIsPreviewOpen,
checkProblems,
selectedAppId,
refetchUserBudget,
],
);
return {

View File

@@ -1,5 +1,12 @@
import { ipcMain } from "electron";
import { CoreMessage, TextPart, ImagePart, streamText } from "ai";
import {
CoreMessage,
TextPart,
ImagePart,
streamText,
ToolSet,
TextStreamPart,
} from "ai";
import { db } from "../../db";
import { chats, messages } from "../../db/schema";
import { and, eq, isNull } from "drizzle-orm";
@@ -14,11 +21,11 @@ import {
import { getDyadAppPath } from "../../paths/paths";
import { readSettings } from "../../main/settings";
import type { ChatResponseEnd, ChatStreamParams } from "../ipc_types";
import { extractCodebase } from "../../utils/codebase";
import { extractCodebase, readFileWithCache } from "../../utils/codebase";
import { processFullResponseActions } from "../processors/response_processor";
import { streamTestResponse } from "./testing_chat_handlers";
import { getTestResponse } from "./testing_chat_handlers";
import { getModelClient } from "../utils/get_model_client";
import { getModelClient, ModelClient } from "../utils/get_model_client";
import log from "electron-log";
import {
getSupabaseContext,
@@ -39,6 +46,12 @@ import { getExtraProviderOptions } from "../utils/thinking_utils";
import { safeSend } from "../utils/safe_sender";
import { cleanFullResponse } from "../utils/cleanFullResponse";
import { generateProblemReport } from "../processors/tsc";
import { createProblemFixPrompt } from "@/shared/problem_prompt";
import { AsyncVirtualFileSystem } from "@/utils/VirtualFilesystem";
import { fileExists } from "../utils/file_utils";
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
const logger = log.scope("chat_stream_handlers");
@@ -68,11 +81,76 @@ async function isTextFile(filePath: string): Promise<boolean> {
return TEXT_FILE_EXTENSIONS.includes(ext);
}
function escapeXml(unsafe: string): string {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// Ensure the temp directory exists
if (!fs.existsSync(TEMP_DIR)) {
fs.mkdirSync(TEMP_DIR, { recursive: true });
}
// Helper function to process stream chunks
async function processStreamChunks({
fullStream,
fullResponse,
abortController,
chatId,
processResponseChunkUpdate,
}: {
fullStream: AsyncIterableStream<TextStreamPart<ToolSet>>;
fullResponse: string;
abortController: AbortController;
chatId: number;
processResponseChunkUpdate: (params: {
fullResponse: string;
}) => Promise<string>;
}): Promise<{ fullResponse: string; incrementalResponse: string }> {
let incrementalResponse = "";
let inThinkingBlock = false;
for await (const part of fullStream) {
let chunk = "";
if (part.type === "text-delta") {
if (inThinkingBlock) {
chunk = "</think>";
inThinkingBlock = false;
}
chunk += part.textDelta;
} else if (part.type === "reasoning") {
if (!inThinkingBlock) {
chunk = "<think>";
inThinkingBlock = true;
}
chunk += escapeDyadTags(part.textDelta);
}
if (!chunk) {
continue;
}
fullResponse += chunk;
incrementalResponse += chunk;
fullResponse = cleanFullResponse(fullResponse);
fullResponse = await processResponseChunkUpdate({
fullResponse,
});
// If the stream was aborted, exit early
if (abortController.signal.aborted) {
logger.log(`Stream for chat ${chatId} was aborted`);
break;
}
}
return { fullResponse, incrementalResponse };
}
export function registerChatStreamHandlers() {
ipcMain.handle("chat:stream", async (event, req: ChatStreamParams) => {
try {
@@ -263,32 +341,24 @@ ${componentSnippet}
// Normal AI processing for non-test prompts
const settings = readSettings();
// Extract codebase information if app is associated with the chat
let codebaseInfo = "";
let files: { path: string; content: string }[] = [];
if (updatedChat.app) {
const appPath = getDyadAppPath(updatedChat.app.path);
try {
const out = await extractCodebase({
appPath,
chatContext: req.selectedComponent
? {
contextPaths: [
{
globPath: req.selectedComponent.relativePath,
},
],
smartContextAutoIncludes: [],
}
: validateChatContext(updatedChat.app.chatContext),
});
codebaseInfo = out.formattedOutput;
files = out.files;
logger.log(`Extracted codebase information from ${appPath}`);
} catch (error) {
logger.error("Error extracting codebase:", error);
}
}
const appPath = getDyadAppPath(updatedChat.app.path);
const chatContext = req.selectedComponent
? {
contextPaths: [
{
globPath: req.selectedComponent.relativePath,
},
],
smartContextAutoIncludes: [],
}
: validateChatContext(updatedChat.app.chatContext);
const { formattedOutput: codebaseInfo, files } = await extractCodebase({
appPath,
chatContext,
});
logger.log(`Extracted codebase information from ${appPath}`);
logger.log(
"codebaseInfo: length",
codebaseInfo.length,
@@ -396,7 +466,7 @@ This conversation includes one or more image attachments. When the user uploads
: ([
{
role: "user",
content: "This is my codebase. " + codebaseInfo,
content: createCodebasePrompt(codebaseInfo),
},
{
role: "assistant",
@@ -413,8 +483,8 @@ This conversation includes one or more image attachments. When the user uploads
// and eats up extra tokens.
content:
settings.selectedChatMode === "ask"
? removeDyadTags(removeThinkingTags(msg.content))
: removeThinkingTags(msg.content),
? removeDyadTags(removeNonEssentialTags(msg.content))
: removeNonEssentialTags(msg.content),
})),
];
@@ -453,8 +523,10 @@ This conversation includes one or more image attachments. When the user uploads
const simpleStreamText = async ({
chatMessages,
modelClient,
}: {
chatMessages: CoreMessage[];
modelClient: ModelClient;
}) => {
return streamText({
maxTokens: await getMaxTokens(settings.selectedModel),
@@ -531,51 +603,21 @@ This conversation includes one or more image attachments. When the user uploads
};
// When calling streamText, the messages need to be properly formatted for mixed content
const { fullStream } = await simpleStreamText({ chatMessages });
const { fullStream } = await simpleStreamText({
chatMessages,
modelClient,
});
// Process the stream as before
let inThinkingBlock = false;
try {
for await (const part of fullStream) {
let chunk = "";
if (part.type === "text-delta") {
if (inThinkingBlock) {
chunk = "</think>";
inThinkingBlock = false;
}
chunk += part.textDelta;
} else if (part.type === "reasoning") {
if (!inThinkingBlock) {
chunk = "<think>";
inThinkingBlock = true;
}
// Escape dyad tags in reasoning content
// We are replacing the opening tag with a look-alike character
// to avoid issues where thinking content includes dyad tags
// and are mishandled by:
// 1. FE markdown parser
// 2. Main process response processor
chunk += part.textDelta
.replace(/<dyad/g, "dyad")
.replace(/<\/dyad/g, "/dyad");
}
if (!chunk) {
continue;
}
fullResponse += chunk;
fullResponse = cleanFullResponse(fullResponse);
fullResponse = await processResponseChunkUpdate({
fullResponse,
});
// If the stream was aborted, exit early
if (abortController.signal.aborted) {
logger.log(`Stream for chat ${req.chatId} was aborted`);
break;
}
}
const result = await processStreamChunks({
fullStream,
fullResponse,
abortController,
chatId: req.chatId,
processResponseChunkUpdate,
});
fullResponse = result.fullResponse;
if (
!abortController.signal.aborted &&
@@ -599,6 +641,7 @@ This conversation includes one or more image attachments. When the user uploads
...chatMessages,
{ role: "assistant", content: fullResponse },
],
modelClient,
});
for await (const part of contStream) {
// If the stream was aborted, exit early
@@ -615,6 +658,117 @@ This conversation includes one or more image attachments. When the user uploads
}
}
}
if (
!abortController.signal.aborted &&
settings.enableAutoFixProblems &&
settings.selectedChatMode !== "ask"
) {
try {
// IF auto-fix is enabled
let problemReport = await generateProblemReport({
fullResponse,
appPath: getDyadAppPath(updatedChat.app.path),
});
let autoFixAttempts = 0;
const originalFullResponse = fullResponse;
const previousAttempts: CoreMessage[] = [];
while (
problemReport.problems.length > 0 &&
autoFixAttempts < 2 &&
!abortController.signal.aborted
) {
fullResponse += `<dyad-problem-report summary="${problemReport.problems.length} problems">
${problemReport.problems
.map(
(problem) =>
`<problem file="${escapeXml(problem.file)}" line="${problem.line}" column="${problem.column}" code="${problem.code}">${escapeXml(problem.message)}</problem>`,
)
.join("\n")}
</dyad-problem-report>`;
logger.info(
`Attempting to auto-fix problems, attempt #${autoFixAttempts + 1}`,
);
autoFixAttempts++;
const problemFixPrompt = createProblemFixPrompt(problemReport);
const virtualFileSystem = new AsyncVirtualFileSystem(
getDyadAppPath(updatedChat.app.path),
{
fileExists: (fileName: string) => fileExists(fileName),
readFile: (fileName: string) => readFileWithCache(fileName),
},
);
virtualFileSystem.applyResponseChanges(fullResponse);
const { formattedOutput: codebaseInfo, files } =
await extractCodebase({
appPath,
chatContext,
virtualFileSystem,
});
const { modelClient } = await getModelClient(
settings.selectedModel,
settings,
files,
);
const { fullStream } = await simpleStreamText({
modelClient,
chatMessages: [
...chatMessages.map((msg, index) => {
if (
index === 0 &&
msg.role === "user" &&
typeof msg.content === "string" &&
msg.content.startsWith(CODEBASE_PROMPT_PREFIX)
) {
return {
role: "user",
content: createCodebasePrompt(codebaseInfo),
} as const;
}
return msg;
}),
{
role: "assistant",
content: originalFullResponse,
},
...previousAttempts,
{ role: "user", content: problemFixPrompt },
],
});
previousAttempts.push({
role: "user",
content: problemFixPrompt,
});
const result = await processStreamChunks({
fullStream,
fullResponse,
abortController,
chatId: req.chatId,
processResponseChunkUpdate,
});
fullResponse = result.fullResponse;
previousAttempts.push({
role: "assistant",
content: result.incrementalResponse,
});
problemReport = await generateProblemReport({
fullResponse,
appPath: getDyadAppPath(updatedChat.app.path),
});
}
} catch (error) {
logger.error(
"Error generating problem report or auto-fixing:",
settings.enableAutoFixProblems,
error,
);
}
}
} catch (streamError) {
// Check if this was an abort error
if (abortController.signal.aborted) {
@@ -901,11 +1055,21 @@ async function prepareMessageWithAttachments(
};
}
function removeNonEssentialTags(text: string): string {
return removeProblemReportTags(removeThinkingTags(text));
}
function removeThinkingTags(text: string): string {
const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
return text.replace(thinkRegex, "").trim();
}
export function removeProblemReportTags(text: string): string {
const problemReportRegex =
/<dyad-problem-report[^>]*>[\s\S]*?<\/dyad-problem-report>/g;
return text.replace(problemReportRegex, "").trim();
}
export function removeDyadTags(text: string): string {
const dyadRegex = /<dyad-[^>]*>[\s\S]*?<\/dyad-[^>]*>/g;
return text.replace(dyadRegex, "").trim();
@@ -932,3 +1096,18 @@ export function hasUnclosedDyadWrite(text: string): boolean {
return !hasClosingTag;
}
function escapeDyadTags(text: string): string {
// Escape dyad tags in reasoning content
// We are replacing the opening tag with a look-alike character
// to avoid issues where thinking content includes dyad tags
// and are mishandled by:
// 1. FE markdown parser
// 2. Main process response processor
return text.replace(/<dyad/g, "dyad").replace(/<\/dyad/g, "/dyad");
}
const CODEBASE_PROMPT_PREFIX = "This is my codebase.";
function createCodebasePrompt(codebaseInfo: string): string {
return `${CODEBASE_PROMPT_PREFIX} ${codebaseInfo}`;
}

View File

@@ -0,0 +1,36 @@
import { db } from "../../db";
import { ipcMain } from "electron";
import { apps } from "../../db/schema";
import { eq } from "drizzle-orm";
import { generateProblemReport } from "../processors/tsc";
import { getDyadAppPath } from "@/paths/paths";
import { logger } from "./app_upgrade_handlers";
export function registerProblemsHandlers() {
// Handler to check problems using autofix with empty response
ipcMain.handle("check-problems", async (event, params: { appId: number }) => {
try {
// Get the app to find its path
const app = await db.query.apps.findFirst({
where: eq(apps.id, params.appId),
});
if (!app) {
throw new Error(`App not found: ${params.appId}`);
}
const appPath = getDyadAppPath(app.path);
// Call autofix with empty full response to just run TypeScript checking
const problemReport = await generateProblemReport({
fullResponse: "",
appPath,
});
return problemReport;
} catch (error) {
logger.error("Error checking problems:", error);
throw error;
}
});
}

View File

@@ -4,6 +4,16 @@ import { cleanFullResponse } from "../utils/cleanFullResponse";
// e.g. [dyad-qa=add-dep]
// Canned responses for test prompts
const TEST_RESPONSES: Record<string, string> = {
"ts-error": `This will get a TypeScript error.
<dyad-write path="src/bad-file.ts" description="This will get a TypeScript error.">
import NonExistentClass from 'non-existent-class';
const x = new Object();
x.nonExistentMethod();
</dyad-write>
EOM`,
"add-dep": `I'll add that dependency for you.
<dyad-add-dependency packages="deno"></dyad-add-dependency>

View File

@@ -9,6 +9,7 @@ import type {
AppOutput,
Chat,
ChatResponseEnd,
ChatProblemsEvent,
CreateAppParams,
CreateAppResult,
ListAppsResponse,
@@ -35,6 +36,7 @@ import type {
App,
ComponentSelection,
AppUpgrade,
ProblemReport,
} from "./ipc_types";
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
import { showError } from "@/lib/toast";
@@ -233,6 +235,7 @@ export class IpcClient {
onUpdate: (messages: Message[]) => void;
onEnd: (response: ChatResponseEnd) => void;
onError: (error: string) => void;
onProblems?: (problems: ChatProblemsEvent) => void;
},
): void {
const {
@@ -934,4 +937,10 @@ export class IpcClient {
public async openAndroid(params: { appId: number }): Promise<void> {
return this.ipcRenderer.invoke("open-android", params);
}
public async checkProblems(params: {
appId: number;
}): Promise<ProblemReport> {
return this.ipcRenderer.invoke("check-problems", params);
}
}

View File

@@ -22,6 +22,7 @@ import { registerProHandlers } from "./handlers/pro_handlers";
import { registerContextPathsHandlers } from "./handlers/context_paths_handlers";
import { registerAppUpgradeHandlers } from "./handlers/app_upgrade_handlers";
import { registerCapacitorHandlers } from "./handlers/capacitor_handlers";
import { registerProblemsHandlers } from "./handlers/problems_handlers";
export function registerIpcHandlers() {
// Register all IPC handlers by category
@@ -33,6 +34,7 @@ export function registerIpcHandlers() {
registerDependencyHandlers();
registerGithubHandlers();
registerNodeHandlers();
registerProblemsHandlers();
registerProposalHandlers();
registerDebugHandlers();
registerSupabaseHandlers();

View File

@@ -31,6 +31,12 @@ export interface ChatResponseEnd {
extraFilesError?: string;
}
export interface ChatProblemsEvent {
chatId: number;
appId: number;
problems: ProblemReport;
}
export interface CreateAppParams {
name: string;
}
@@ -240,3 +246,15 @@ export interface AppUpgrade {
manualUpgradeUrl: string;
isNeeded: boolean;
}
export interface Problem {
file: string;
line: number;
column: number;
message: string;
code: number;
}
export interface ProblemReport {
problems: Problem[];
}

View File

@@ -0,0 +1,10 @@
/**
* Normalize the path to use forward slashes instead of backslashes.
* This is important to prevent weird Git issues, particularly on Windows.
* @param path Source path.
* @returns Normalized path.
*/
export function normalizePath(path: string): string {
return path.replace(/\\/g, "/");
}

View File

@@ -19,20 +19,11 @@ import { SqlQuery, UserSettings } from "../../lib/schemas";
import { gitCommit } from "../utils/git_utils";
import { readSettings } from "@/main/settings";
import { writeMigrationFile } from "../utils/file_utils";
import { normalizePath } from "./normalizePath";
const readFile = fs.promises.readFile;
const logger = log.scope("response_processor");
/**
* Normalize the path to use forward slashes instead of backslashes.
* This is important to prevent weird Git issues, particularly on Windows.
* @param path Source path.
* @returns Normalized path.
*/
function normalizePath(path: string): string {
return path.replace(/\\/g, "/");
}
export function getDyadWriteTags(fullResponse: string): {
path: string;
content: string;

211
src/ipc/processors/tsc.ts Normal file
View File

@@ -0,0 +1,211 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { ProblemReport } from "../ipc_types";
import { Problem } from "../ipc_types";
import { normalizePath } from "./normalizePath";
import { SyncVirtualFileSystem } from "../../utils/VirtualFilesystem";
import log from "electron-log";
const logger = log.scope("tsc");
function loadLocalTypeScript(appPath: string): typeof import("typescript") {
try {
// Try to load TypeScript from the project's node_modules
const requirePath = require.resolve("typescript", { paths: [appPath] });
logger.info(`Loading TypeScript from ${requirePath} for app ${appPath}`);
const ts = require(requirePath);
return ts;
} catch (error) {
throw new Error(
`Failed to load TypeScript from ${appPath} because of ${error}`,
);
}
}
export async function generateProblemReport({
fullResponse,
appPath,
}: {
fullResponse: string;
appPath: string;
}): Promise<ProblemReport> {
// Load the local TypeScript version from the app's node_modules
const ts = loadLocalTypeScript(appPath);
// Create virtual file system with TypeScript system delegate and apply changes from response
const vfs = new SyncVirtualFileSystem(appPath, {
fileExists: (fileName: string) => ts.sys.fileExists(fileName),
readFile: (fileName: string) => ts.sys.readFile(fileName),
});
vfs.applyResponseChanges(fullResponse);
// Find TypeScript config - throw error if not found
const tsconfigPath = findTypeScriptConfig(appPath);
// Create TypeScript program with virtual file system
const result = await runTypeScriptCheck(ts, appPath, tsconfigPath, vfs);
return result;
}
function findTypeScriptConfig(appPath: string): string {
const possibleConfigs = [
// For vite applications, we want to check tsconfig.app.json, since it's the
// most important one (client-side app).
// The tsconfig.json in vite apps is a project reference and doesn't
// actually check anything unless you do "--build" which requires a complex
// programmatic approach
"tsconfig.app.json",
// For Next.js applications, it typically has a single tsconfig.json file
"tsconfig.json",
];
for (const config of possibleConfigs) {
const configPath = path.join(appPath, config);
if (fs.existsSync(configPath)) {
return configPath;
}
}
throw new Error(
`No TypeScript configuration file found in ${appPath}. Expected one of: ${possibleConfigs.join(", ")}`,
);
}
async function runTypeScriptCheck(
ts: typeof import("typescript"),
appPath: string,
tsconfigPath: string,
vfs: SyncVirtualFileSystem,
): Promise<ProblemReport> {
return runSingleProject(ts, appPath, tsconfigPath, vfs);
}
async function runSingleProject(
ts: typeof import("typescript"),
appPath: string,
tsconfigPath: string,
vfs: SyncVirtualFileSystem,
): Promise<ProblemReport> {
// Use the idiomatic way to parse TypeScript config
const parsedCommandLine = ts.getParsedCommandLineOfConfigFile(
tsconfigPath,
undefined, // No additional options
{
// Custom system object that can handle our virtual files
...ts.sys,
fileExists: (fileName: string) => vfs.fileExists(fileName),
readFile: (fileName: string) => vfs.readFile(fileName),
onUnRecoverableConfigFileDiagnostic: (
diagnostic: import("typescript").Diagnostic,
) => {
throw new Error(
`TypeScript config error: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`,
);
},
},
);
if (!parsedCommandLine) {
throw new Error(`Failed to parse TypeScript config: ${tsconfigPath}`);
}
let rootNames = parsedCommandLine.fileNames;
// Add any virtual files that aren't already included
const virtualTsFiles = vfs
.getVirtualFiles()
.map((file) => path.resolve(appPath, file.path))
.filter(isTypeScriptFile);
// Remove deleted files from rootNames
const deletedFiles = vfs
.getDeletedFiles()
.map((file) => path.resolve(appPath, file));
rootNames = rootNames.filter((fileName) => {
const resolvedPath = path.resolve(fileName);
return !deletedFiles.includes(resolvedPath);
});
for (const virtualFile of virtualTsFiles) {
if (!rootNames.includes(virtualFile)) {
rootNames.push(virtualFile);
}
}
// Create custom compiler host
const host = createVirtualCompilerHost(
ts,
appPath,
vfs,
parsedCommandLine.options,
);
// Create TypeScript program - this is the idiomatic way
const program = ts.createProgram(rootNames, parsedCommandLine.options, host);
// Get diagnostics
const diagnostics = [
...program.getSyntacticDiagnostics(),
...program.getSemanticDiagnostics(),
...program.getGlobalDiagnostics(),
];
// Convert diagnostics to our format
const problems: Problem[] = [];
for (const diagnostic of diagnostics) {
if (!diagnostic.file) continue;
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(
diagnostic.start!,
);
const message = ts.flattenDiagnosticMessageText(
diagnostic.messageText,
"\n",
);
if (diagnostic.category !== ts.DiagnosticCategory.Error) {
continue;
}
problems.push({
file: normalizePath(path.relative(appPath, diagnostic.file.fileName)),
line: line + 1, // Convert to 1-based
column: character + 1, // Convert to 1-based
message,
code: diagnostic.code,
});
}
return {
problems,
};
}
function createVirtualCompilerHost(
ts: typeof import("typescript"),
appPath: string,
vfs: SyncVirtualFileSystem,
compilerOptions: import("typescript").CompilerOptions,
): import("typescript").CompilerHost {
const host = ts.createCompilerHost(compilerOptions);
// Override file reading to use virtual files
host.readFile = (fileName: string) => {
return vfs.readFile(fileName);
};
// Override file existence check
host.fileExists = (fileName: string) => {
return vfs.fileExists(fileName);
};
return host;
}
function isTypeScriptFile(fileName: string): boolean {
const ext = path.extname(fileName).toLowerCase();
return [".ts", ".tsx", ".js", ".jsx"].includes(ext);
}

View File

@@ -92,3 +92,10 @@ export async function writeMigrationFile(
await fsExtra.writeFile(migrationFilePath, queryContent);
}
export async function fileExists(filePath: string) {
return fsPromises
.access(filePath)
.then(() => true)
.catch(() => false);
}

View File

@@ -149,6 +149,7 @@ export const UserSettingsSchema = z.object({
enableSupabaseWriteSqlMigration: z.boolean().optional(),
selectedChatMode: ChatModeSchema.optional(),
enableAutoFixProblems: z.boolean().optional(),
enableNativeGit: z.boolean().optional(),
////////////////////////////////

View File

@@ -20,6 +20,7 @@ const DEFAULT_SETTINGS: UserSettings = {
enableProLazyEditsMode: true,
enableProSmartFilesContextMode: true,
selectedChatMode: "build",
enableAutoFixProblems: true,
};
const SETTINGS_FILE = "user-settings.json";

View File

@@ -17,6 +17,7 @@ import { GitHubIntegration } from "@/components/GitHubIntegration";
import { SupabaseIntegration } from "@/components/SupabaseIntegration";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { AutoFixProblemsSwitch } from "@/components/AutoFixProblemsSwitch";
export default function SettingsPage() {
const { theme, setTheme } = useTheme();
@@ -110,6 +111,13 @@ export default function SettingsPage() {
</div>
</div>
<div className="space-y-1 mt-4">
<AutoFixProblemsSwitch />
<div className="text-sm text-gray-500 dark:text-gray-400">
This will automatically fix TypeScript errors.
</div>
</div>
<div className="space-y-1 mt-4">
<div className="flex items-center space-x-2">
<Switch

View File

@@ -88,6 +88,7 @@ const validInvokeChannels = [
"sync-capacitor",
"open-ios",
"open-android",
"check-problems",
// 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

View File

@@ -0,0 +1,24 @@
import type { ProblemReport } from "../ipc/ipc_types";
/**
* Creates a more concise version of the problem fix prompt for cases where
* brevity is preferred.
*/
export function createProblemFixPrompt(problemReport: ProblemReport): string {
const { problems } = problemReport;
if (problems.length === 0) {
return "No TypeScript problems detected.";
}
const totalProblems = problems.length;
let prompt = `Fix these ${totalProblems} TypeScript compile-time error${totalProblems === 1 ? "" : "s"}:\n\n`;
problems.forEach((problem, index) => {
prompt += `${index + 1}. ${problem.file}:${problem.line}:${problem.column} - ${problem.message} (TS${problem.code})\n`;
});
prompt += "\nPlease fix all errors in a concise way.";
return prompt;
}

View File

@@ -0,0 +1,373 @@
import * as fs from "node:fs";
import * as path from "node:path";
import {
getDyadWriteTags,
getDyadRenameTags,
getDyadDeleteTags,
} from "../ipc/processors/response_processor";
import log from "electron-log";
const logger = log.scope("VirtualFileSystem");
export interface VirtualFile {
path: string;
content: string;
}
export interface VirtualRename {
from: string;
to: string;
}
export interface SyncFileSystemDelegate {
fileExists?: (fileName: string) => boolean;
readFile?: (fileName: string) => string | undefined;
}
export interface AsyncFileSystemDelegate {
fileExists?: (fileName: string) => Promise<boolean>;
readFile?: (fileName: string) => Promise<string | undefined>;
}
/**
* Base class containing shared virtual filesystem functionality
*/
export abstract class BaseVirtualFileSystem {
protected virtualFiles = new Map<string, string>();
protected deletedFiles = new Set<string>();
protected baseDir: string;
constructor(baseDir: string) {
this.baseDir = baseDir;
}
/**
* Apply changes from a response containing dyad tags
*/
public applyResponseChanges(fullResponse: string): void {
const writeTags = getDyadWriteTags(fullResponse);
const renameTags = getDyadRenameTags(fullResponse);
const deletePaths = getDyadDeleteTags(fullResponse);
// Process deletions
for (const deletePath of deletePaths) {
this.deleteFile(deletePath);
}
// Process renames (delete old, create new)
for (const rename of renameTags) {
this.renameFile(rename.from, rename.to);
}
// Process writes
for (const writeTag of writeTags) {
this.writeFile(writeTag.path, writeTag.content);
}
}
/**
* Write a file to the virtual filesystem
*/
public writeFile(relativePath: string, content: string): void {
const absolutePath = path.resolve(this.baseDir, relativePath);
this.virtualFiles.set(absolutePath, content);
// Remove from deleted files if it was previously deleted
this.deletedFiles.delete(absolutePath);
}
/**
* Delete a file from the virtual filesystem
*/
public deleteFile(relativePath: string): void {
const absolutePath = path.resolve(this.baseDir, relativePath);
this.deletedFiles.add(absolutePath);
// Remove from virtual files if it exists there
this.virtualFiles.delete(absolutePath);
}
/**
* Rename a file in the virtual filesystem
*/
public renameFile(fromPath: string, toPath: string): void {
const fromAbsolute = path.resolve(this.baseDir, fromPath);
const toAbsolute = path.resolve(this.baseDir, toPath);
// Mark old file as deleted
this.deletedFiles.add(fromAbsolute);
// If the source file exists in virtual files, move its content
if (this.virtualFiles.has(fromAbsolute)) {
const content = this.virtualFiles.get(fromAbsolute)!;
this.virtualFiles.delete(fromAbsolute);
this.virtualFiles.set(toAbsolute, content);
} else {
// Try to read from actual filesystem
try {
const content = fs.readFileSync(fromAbsolute, "utf8");
this.virtualFiles.set(toAbsolute, content);
} catch (error) {
// If we can't read the source file, we'll let the consumer handle it
logger.warn(
`Could not read source file for rename: ${fromPath}`,
error,
);
}
}
// Remove destination from deleted files if it was previously deleted
this.deletedFiles.delete(toAbsolute);
}
/**
* Get all virtual files (files that have been written or modified)
*/
public getVirtualFiles(): VirtualFile[] {
return Array.from(this.virtualFiles.entries()).map(
([absolutePath, content]) => ({
path: path.relative(this.baseDir, absolutePath),
content,
}),
);
}
/**
* Get all deleted file paths (relative to base directory)
*/
public getDeletedFiles(): string[] {
return Array.from(this.deletedFiles).map((absolutePath) =>
path.relative(this.baseDir, absolutePath),
);
}
/**
* Get all files that should be considered (existing + virtual - deleted)
*/
public getAllFiles(): string[] {
const allFiles = new Set<string>();
// Add virtual files
for (const [absolutePath] of this.virtualFiles.entries()) {
allFiles.add(path.relative(this.baseDir, absolutePath));
}
// Add existing files (this is a simplified version - in practice you might want to scan the directory)
// This method is mainly for getting the current state, consumers can combine with directory scanning
return Array.from(allFiles);
}
/**
* Check if a file has been modified in the virtual filesystem
*/
public isFileModified(filePath: string): boolean {
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(this.baseDir, filePath);
return (
this.virtualFiles.has(absolutePath) || this.deletedFiles.has(absolutePath)
);
}
/**
* Clear all virtual changes
*/
public clear(): void {
this.virtualFiles.clear();
this.deletedFiles.clear();
}
/**
* Get the base directory
*/
public getBaseDir(): string {
return this.baseDir;
}
/**
* Check if a file is deleted in the virtual filesystem
*/
protected isDeleted(filePath: string): boolean {
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(this.baseDir, filePath);
return this.deletedFiles.has(absolutePath);
}
/**
* Check if a file exists in virtual files
*/
protected hasVirtualFile(filePath: string): boolean {
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(this.baseDir, filePath);
return this.virtualFiles.has(absolutePath);
}
/**
* Get virtual file content
*/
protected getVirtualFileContent(filePath: string): string | undefined {
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(this.baseDir, filePath);
return this.virtualFiles.get(absolutePath);
}
}
/**
* Synchronous virtual filesystem
*/
export class SyncVirtualFileSystem extends BaseVirtualFileSystem {
private delegate: SyncFileSystemDelegate;
constructor(baseDir: string, delegate?: SyncFileSystemDelegate) {
super(baseDir);
this.delegate = delegate || {};
}
/**
* Check if a file exists in the virtual filesystem
*/
public fileExists(filePath: string): boolean {
// Check if file is deleted
if (this.isDeleted(filePath)) {
return false;
}
// Check if file exists in virtual files
if (this.hasVirtualFile(filePath)) {
return true;
}
// Delegate to custom fileExists if provided
if (this.delegate.fileExists) {
return this.delegate.fileExists(filePath);
}
// Fall back to actual filesystem
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(this.baseDir, filePath);
return fs.existsSync(absolutePath);
}
/**
* Read a file from the virtual filesystem
*/
public readFile(filePath: string): string | undefined {
// Check if file is deleted
if (this.isDeleted(filePath)) {
return undefined;
}
// Check virtual files first
const virtualContent = this.getVirtualFileContent(filePath);
if (virtualContent !== undefined) {
return virtualContent;
}
// Delegate to custom readFile if provided
if (this.delegate.readFile) {
return this.delegate.readFile(filePath);
}
// Fall back to actual filesystem
try {
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(this.baseDir, filePath);
return fs.readFileSync(absolutePath, "utf8");
} catch {
return undefined;
}
}
/**
* Create a custom file system interface for other tools
*/
public createFileSystemInterface() {
return {
fileExists: (fileName: string) => this.fileExists(fileName),
readFile: (fileName: string) => this.readFile(fileName),
writeFile: (fileName: string, content: string) =>
this.writeFile(fileName, content),
deleteFile: (fileName: string) => this.deleteFile(fileName),
};
}
}
/**
* Asynchronous virtual filesystem
*/
export class AsyncVirtualFileSystem extends BaseVirtualFileSystem {
private delegate: AsyncFileSystemDelegate;
constructor(baseDir: string, delegate?: AsyncFileSystemDelegate) {
super(baseDir);
this.delegate = delegate || {};
}
/**
* Check if a file exists in the virtual filesystem
*/
public async fileExists(filePath: string): Promise<boolean> {
// Check if file is deleted
if (this.isDeleted(filePath)) {
return false;
}
// Check if file exists in virtual files
if (this.hasVirtualFile(filePath)) {
return true;
}
// Delegate to custom fileExists if provided
if (this.delegate.fileExists) {
return this.delegate.fileExists(filePath);
}
// Fall back to actual filesystem
try {
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(this.baseDir, filePath);
await fs.promises.access(absolutePath);
return true;
} catch {
return false;
}
}
/**
* Read a file from the virtual filesystem
*/
public async readFile(filePath: string): Promise<string | undefined> {
// Check if file is deleted
if (this.isDeleted(filePath)) {
return undefined;
}
// Check virtual files first
const virtualContent = this.getVirtualFileContent(filePath);
if (virtualContent !== undefined) {
return virtualContent;
}
// Delegate to custom readFile if provided
if (this.delegate.readFile) {
return this.delegate.readFile(filePath);
}
// Fall back to actual filesystem
try {
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(this.baseDir, filePath);
return await fs.promises.readFile(absolutePath, "utf8");
} catch {
return undefined;
}
}
}

View File

@@ -7,6 +7,7 @@ import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
import { glob } from "glob";
import { AppChatContext } from "../lib/schemas";
import { readSettings } from "@/main/settings";
import { AsyncVirtualFileSystem } from "./VirtualFilesystem";
const logger = log.scope("utils/codebase");
@@ -156,8 +157,19 @@ async function isGitIgnored(
/**
* Read file contents with caching based on last modified time
*/
async function readFileWithCache(filePath: string): Promise<string | null> {
export async function readFileWithCache(
filePath: string,
virtualFileSystem?: AsyncVirtualFileSystem,
): Promise<string | undefined> {
try {
// Check virtual filesystem first if provided
if (virtualFileSystem) {
const virtualContent = await virtualFileSystem.readFile(filePath);
if (virtualContent != null) {
return cleanContent({ content: virtualContent, filePath });
}
}
// Get file stats to check the modification time
const stats = await fsAsync.stat(filePath);
const currentMtime = stats.mtimeMs;
@@ -193,7 +205,7 @@ async function readFileWithCache(filePath: string): Promise<string | null> {
return content;
} catch (error) {
logger.error(`Error reading file: ${filePath}`, error);
return null;
return undefined;
}
}
@@ -304,7 +316,11 @@ const OMITTED_FILE_CONTENT = "// Contents omitted for brevity";
/**
* Format a file for inclusion in the codebase extract
*/
async function formatFile(filePath: string, baseDir: string): Promise<string> {
async function formatFile(
filePath: string,
baseDir: string,
virtualFileSystem?: AsyncVirtualFileSystem,
): Promise<string> {
try {
const relativePath = path
.relative(baseDir, filePath)
@@ -320,9 +336,9 @@ ${OMITTED_FILE_CONTENT}
`;
}
const content = await readFileWithCache(filePath);
const content = await readFileWithCache(filePath, virtualFileSystem);
if (content === null) {
if (content == null) {
return `<dyad-file path="${relativePath}">
// Error reading file
</dyad-file>
@@ -354,14 +370,17 @@ export type CodebaseFile = {
/**
* Extract and format codebase files as a string to be included in prompts
* @param appPath - Path to the codebase to extract
* @param virtualFileSystem - Optional virtual filesystem to apply modifications
* @returns Object containing formatted output and individual files
*/
export async function extractCodebase({
appPath,
chatContext,
virtualFileSystem,
}: {
appPath: string;
chatContext: AppChatContext;
virtualFileSystem?: AsyncVirtualFileSystem;
}): Promise<{
formattedOutput: string;
files: CodebaseFile[];
@@ -383,6 +402,26 @@ export async function extractCodebase({
// Collect all relevant files
let files = await collectFiles(appPath, appPath);
// Apply virtual filesystem modifications if provided
if (virtualFileSystem) {
// Filter out deleted files
const deletedFiles = new Set(
virtualFileSystem
.getDeletedFiles()
.map((relativePath) => path.resolve(appPath, relativePath)),
);
files = files.filter((file) => !deletedFiles.has(file));
// Add virtual files
const virtualFiles = virtualFileSystem.getVirtualFiles();
for (const virtualFile of virtualFiles) {
const absolutePath = path.resolve(appPath, virtualFile.path);
if (!files.includes(absolutePath)) {
files.push(absolutePath);
}
}
}
// Collect files from contextPaths and smartContextAutoIncludes
const { contextPaths, smartContextAutoIncludes } = chatContext;
const includedFiles = new Set<string>();
@@ -443,7 +482,7 @@ export async function extractCodebase({
// Format files and collect individual file contents
const filesArray: CodebaseFile[] = [];
const formatPromises = sortedFiles.map(async (file) => {
const formattedContent = await formatFile(file, appPath);
const formattedContent = await formatFile(file, appPath, virtualFileSystem);
// Get raw content for the files array
const relativePath = path
@@ -456,8 +495,8 @@ export async function extractCodebase({
const fileContent = isOmittedFile(relativePath)
? OMITTED_FILE_CONTENT
: await readFileWithCache(file);
if (fileContent !== null) {
: await readFileWithCache(file, virtualFileSystem);
if (fileContent != null) {
filesArray.push({
path: relativePath,
content: fileContent,
@@ -498,7 +537,8 @@ async function sortFilesByModificationTime(files: string[]): Promise<string[]> {
return { file, mtime: stats.mtimeMs };
} catch (error) {
// If there's an error getting stats, use current time as fallback
logger.error(`Error getting file stats for ${file}:`, error);
// This can happen with virtual files, so it's not a big deal.
logger.warn(`Error getting file stats for ${file}:`, error);
return { file, mtime: Date.now() };
}
}),