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

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