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:
63
src/__tests__/__snapshots__/problem_prompt.test.ts.snap
Normal file
63
src/__tests__/__snapshots__/problem_prompt.test.ts.snap
Normal 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."
|
||||
`;
|
||||
214
src/__tests__/problem_prompt.test.ts
Normal file
214
src/__tests__/problem_prompt.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<
|
||||
|
||||
21
src/components/AutoFixProblemsSwitch.tsx
Normal file
21
src/components/AutoFixProblemsSwitch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
154
src/components/chat/DyadProblemSummary.tsx
Normal file
154
src/components/chat/DyadProblemSummary.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
208
src/components/preview_panel/Problems.tsx
Normal file
208
src/components/preview_panel/Problems.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/hooks/useCheckProblems.ts
Normal file
30
src/hooks/useCheckProblems.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// 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}`;
|
||||
}
|
||||
|
||||
36
src/ipc/handlers/problems_handlers.ts
Normal file
36
src/ipc/handlers/problems_handlers.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
10
src/ipc/processors/normalizePath.ts
Normal file
10
src/ipc/processors/normalizePath.ts
Normal 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, "/");
|
||||
}
|
||||
@@ -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
211
src/ipc/processors/tsc.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -149,6 +149,7 @@ export const UserSettingsSchema = z.object({
|
||||
enableSupabaseWriteSqlMigration: z.boolean().optional(),
|
||||
selectedChatMode: ChatModeSchema.optional(),
|
||||
|
||||
enableAutoFixProblems: z.boolean().optional(),
|
||||
enableNativeGit: z.boolean().optional(),
|
||||
|
||||
////////////////////////////////
|
||||
|
||||
@@ -20,6 +20,7 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
enableProLazyEditsMode: true,
|
||||
enableProSmartFilesContextMode: true,
|
||||
selectedChatMode: "build",
|
||||
enableAutoFixProblems: true,
|
||||
};
|
||||
|
||||
const SETTINGS_FILE = "user-settings.json";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
24
src/shared/problem_prompt.ts
Normal file
24
src/shared/problem_prompt.ts
Normal 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;
|
||||
}
|
||||
373
src/utils/VirtualFilesystem.ts
Normal file
373
src/utils/VirtualFilesystem.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user